Table of Contents

Common Pitfalls

This guide covers frequently encountered issues and misunderstandings when working with WebDriverBiDi.NET. Understanding these pitfalls will help you avoid common mistakes and build more robust automation.

Event Handler Execution

Pitfall: Blocking the Transport Thread with Synchronous Handlers

The Problem:

By default, event handlers run synchronously on the transport thread, which blocks all message processing until the handler completes. This can cause serious performance issues.

// ❌ BAD: Blocks transport thread for 5 seconds
driver.Network.OnBeforeRequestSent.AddObserver((e) =>
{
    Thread.Sleep(5000);
    Console.WriteLine($"Request: {e.Request.Url}");

    // During these 5 seconds:
    // - No other events are processed
    // - No command responses are received
    // - Commands may timeout
    // - The browser may become unresponsive
});

Why This Happens:

WebDriverBiDi.NET processes all incoming messages from the browser on a single reader task, which runs on the thread pool. When your handler blocks it, nothing else can be processed.

The Solution:

Use ObservableEventHandlerOptions.RunHandlerAsynchronously for any handler that:

  • Performs I/O operations (file, network, database)
  • Does CPU-intensive work
  • Calls other async APIs
  • Takes more than a few milliseconds
// ✅ GOOD: Runs asynchronously without blocking
driver.Network.OnBeforeRequestSent.AddObserver(
    async (e) =>
    {
        await Task.Delay(5000);  // Doesn't block transport thread
        Console.WriteLine($"Request: {e.Request.Url}");

        // Transport thread continues processing other messages
        // while the continuation after 'await' runs on a thread pool
        // thread. Note: the handler is still *invoked* on the transport
        // thread; the option detaches the returned Task, so put the
        // await before any heavy work.
    },
    ObservableEventHandlerOptions.RunHandlerAsynchronously
);

One Caveat: the option detaches the Task the handler returns; it does not move where the handler starts. The handler is still invoked on the transport thread, so in an async lambda the code before the first real await runs there, and a non-async handler that does its work and then returns Task.CompletedTask is not offloaded at all. Write the handler as async and await before the heavy work (an await Task.Yield(); first statement guarantees it), or wrap the work in Task.Run. Action<T> handlers are queued to the thread pool as a whole and need no such care. The BIDI007/BIDI023 analyzers flag handlers where the option cannot help, including blocking work or a module command placed before an async handler's first await.

When Synchronous is OK:

Synchronous handlers are fine for quick operations:

// ✅ Fine: Quick in-memory operation
int requestCount = 0;
driver.Network.OnBeforeRequestSent.AddObserver((e) =>
{
    requestCount++;
});

// ✅ Fine: Simple logging to console
driver.Log.OnEntryAdded.AddObserver((e) =>
{
    Console.WriteLine($"[{e.Level}] {e.Text}");
});

Key Takeaway: If your handler does anything more than updating in-memory state or simple console output, use RunHandlerAsynchronously.


Event Subscription

Pitfall: Forgetting the Two-Step Subscription Process

The Problem:

Many developers expect that adding an observer is sufficient to receive events. It's not.

// ❌ INCOMPLETE: Observer added but no events will be received
driver.Log.OnEntryAdded.AddObserver((e) =>
{
    Console.WriteLine(e.Text);
});

await driver.BrowsingContext.NavigateAsync(navParams);
// No log events will fire - you forgot to subscribe!

Why This Design:

The two-step process (add observer + subscribe) is intentional and prevents race conditions. It ensures:

  1. Your handlers are in place before events start flowing
  2. You have explicit control over which events are subscribed
  3. You can scope subscriptions to specific browsing contexts

The Solution:

Always add observer first, then subscribe through the Session module:

// ✅ CORRECT: Add observer AND subscribe
// Step 1: Add observer
driver.Log.OnEntryAdded.AddObserver((e) =>
{
    Console.WriteLine(e.Text);
});

// Step 2: Subscribe to events
SubscribeCommandParameters subscribe = new SubscribeCommandParameters(driver.Log.OnEntryAdded.EventName);
await driver.Session.SubscribeAsync(subscribe);

// Now events will be received
await driver.BrowsingContext.NavigateAsync(navParams);

Best Practice - Subscribe Multiple Events at Once:

// Add all observers first
driver.Log.OnEntryAdded.AddObserver(logHandler);
driver.Network.OnBeforeRequestSent.AddObserver(networkHandler);
driver.BrowsingContext.OnLoad.AddObserver(loadHandler);

// Then subscribe to all events in one call
SubscribeCommandParameters subscribe = new SubscribeCommandParameters(
    [
        driver.Log.OnEntryAdded.EventName,
        driver.Network.OnBeforeRequestSent.EventName,
        driver.BrowsingContext.OnLoad.EventName,
    ]
);
await driver.Session.SubscribeAsync(subscribe);

Key Takeaway: Adding an observer only registers your handler locally. You must explicitly subscribe through Session.SubscribeAsync() to tell the browser to send events.


Module Registration Timing

Pitfall: Registering Modules After StartAsync()

The Problem:

Attempting to register custom modules (RegisterModule), custom events (RegisterEvent), or JSON type info resolvers (RegisterTypeInfoResolverAsync) after calling StartAsync() throws an InvalidOperationException.

// ❌ WRONG: Registration after starting
BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));
await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");

// This will throw InvalidOperationException!
driver.RegisterModule(new CustomModule(driver));

// Adding an observer is NOT restricted; this is allowed after StartAsync.
// Just be sure to add observers before calling Session.SubscribeAsync.
driver.Log.OnEntryAdded.AddObserver(handler);

Why This Restriction:

This timing restriction ensures:

  1. Every event the transport can receive has a registered deserializer before messages start flowing
  2. No race conditions between module registration and event dispatch
  3. Predictable initialization order
  4. Thread-safe module setup

What is not restricted: adding observers with AddObserver (or data collectors with AddDataCollector) is allowed at any time, before or after StartAsync(), and is thread-safe with respect to event dispatch. The ordering that matters for observers is relative to Session.SubscribeAsync(): add the observer first so that no events are missed once the browser starts sending them (see Event Subscription). Adding observers before StartAsync() is simply the most convenient place to do it.

The Solution:

Register modules, custom events, and type resolvers BEFORE calling StartAsync(); add observers before subscribing:

// ✅ CORRECT: Registration before starting
BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));

// 1. Register custom modules (if any)
driver.RegisterModule(new CustomModule(driver));

// 2. Add event observers
driver.Log.OnEntryAdded.AddObserver((e) => Console.WriteLine(e.Text));
driver.BrowsingContext.OnLoad.AddObserver((e) => Console.WriteLine($"Loaded: {e.Url}"));

// 3. NOW start the driver
await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");

// 4. Subscribe to events through Session module
SubscribeCommandParameters subscribe = new SubscribeCommandParameters(
    [
        driver.Log.OnEntryAdded.EventName,
        driver.BrowsingContext.OnLoad.EventName,
    ]
);
await driver.Session.SubscribeAsync(subscribe);

// 5. Execute commands
await driver.BrowsingContext.NavigateAsync(navParams);

Correct Initialization Order:

1. Create BiDiDriver
2. Register custom modules (if needed)
3. Add event observers (recommended here, but permitted at any time)
4. Call StartAsync()
5. Subscribe to events via Session.SubscribeAsync()
6. Execute commands

Key Takeaway: Think of module, custom event, and type resolver registration as "configuration" that must happen before "connection" (StartAsync). Observers are not part of that restriction; just make sure they are in place before you subscribe.


Null vs Empty Collections

Pitfall: Not Knowing Which Shape an Optional List Has

The Problem:

Sent types — the CommandParameters classes and the objects nested inside them — expose optional lists in two shapes, and the way you add items differs:

  • Read-only, always initialized — List<string> Contexts { get; }, UserContexts, StartNodes, Arguments, UrlPatterns, PageRanges, and every other optional list. Add items with a collection initializer or .Add(). An empty list means "not specified": the property is omitted from the payload, and an empty array is never sent (for lists the protocol requires to be non-empty, the browser would reject it; for the rest, omission and [] mean the same thing).
  • Nullable and settable — Headers and Cookies on ContinueRequestCommandParameters, ContinueResponseCommandParameters and ProvideResponseCommandParameters, plus Brands and FullVersionList on ClientHintsMetadata, with FormFactors shaped like them for parity although the specification's emulation does not yet use it. Here the protocol gives a present-but-empty array its own meaning — [] replaces the headers or cookies with none, or overrides the browser's own client hint with an empty value, while omission keeps the originals — so null omits the property and an empty list sends [].
// Shape 1: read-only, always initialized. Every optional list
// (contexts, userContexts, startNodes, arguments, urlPatterns, ...).
SetLocaleOverrideCommandParameters localeParams = new SetLocaleOverrideCommandParameters()
{
    Locale = "en-US",
    Contexts = { contextId },          // collection initializer - no `new List<string>()`
};
localeParams.UserContexts.Add(userContextId);   // ...or add later

// Shape 2: nullable and settable. The headers and cookies of the network continueRequest,
// continueResponse and provideResponse commands, and Brands, FullVersionList and FormFactors on
// ClientHintsMetadata, where the protocol distinguishes an absent field from an empty array.
continueParams.Headers = [];           // sends "headers": []
ClientHintsMetadata clientHints = new ClientHintsMetadata
{
    Brands = [],                       // sends "brands": [], overriding the browser's own with none
    FullVersionList = null,            // omitted, so the browser's own is kept
};

Example Protocol Difference:

// Read-only lists: empty means "not specified" and the property is omitted
SetLocaleOverrideCommandParameters p1 = new SetLocaleOverrideCommandParameters()
{
    Locale = "en-US",
};
// JSON sent: { "locale": "en-US" }   (no "contexts"; the override applies everywhere)

// Read-only lists: items are sent as an array
SetLocaleOverrideCommandParameters p2 = new SetLocaleOverrideCommandParameters()
{
    Locale = "en-US",
    Contexts = { "<valid browsing context ID>" },
};
// JSON sent: { "locale": "en-US", "contexts": ["<valid browsing context ID>"] }

// There is no way to send "contexts": [] - the browser rejects it as an invalid
// argument, so the library does not let you express it.

// Nullable lists keep all three states
continueParams.Headers = null;   // "headers" omitted: keep the original headers
continueParams.Headers = [];     // "headers": []  - send the request with no headers

The Solution:

Read-only lists need no initialization. For nullable lists, initialize before adding items (analyzer BIDI017 flags a missing ??=), or assign the whole list:

// ✅ Read-only lists: nothing to initialize, just add
localeParams.Contexts.Add("<valid browsing context ID>");

// ✅ Nullable lists: initialize before adding (analyzer BIDI017 flags a missing ??=)
continueParams.Headers ??= [];
continueParams.Headers.Add(header);

// ✅ Nullable lists: or assign in one step
continueParams.Headers = [header];

Key Takeaway: Optional lists are read-only and omitted while empty. The six network Headers/Cookies lists and the three ClientHintsMetadata lists (FormFactors for parity with the other two) are the exceptions, nullable so that "omit" (null) and "send []" (empty list) stay distinguishable where the protocol tells them apart.


Command Timeouts

Pitfall: Not Understanding the Default Timeout

The Problem:

Developers are sometimes surprised that the default command timeout is 60 seconds. That is BiDiDriver.DefaultCommandWaitTimeout, which a driver uses when it is constructed without a timeout.

// Default timeout is 60 seconds!
BiDiDriver driver = new BiDiDriver();

// This command has 60 seconds to complete
await driver.BrowsingContext.NavigateAsync(navParams);

Why 60 Seconds:

The high default timeout is intentional because:

  1. Browser automation can involve genuinely slow operations
  2. Page loads can take a long time (slow networks, heavy pages)
  3. Script execution might be CPU-intensive
  4. Network requests in tests might be slow
  5. Better to have a long default than frequent timeouts

The Solution:

Set appropriate timeouts for your use case. Prefer the timeoutOverride parameter on module methods (e.g., NavigateAsync(parameters, TimeSpan.FromSeconds(60))) over ExecuteCommandAsync when you need per-command overrides:

// ✅ For fast operations (local testing)
BiDiDriver fastDriver = new BiDiDriver(TimeSpan.FromSeconds(10));

// ✅ For normal web automation
BiDiDriver normalDriver = new BiDiDriver(TimeSpan.FromSeconds(30));

// ✅ For slow operations or large pages
BiDiDriver slowDriver = new BiDiDriver(TimeSpan.FromMinutes(2));

// ✅ Override per-command for specific cases (preferred: use timeoutOverride on module method)
BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));
// Most commands use 30 second timeout

// But this specific navigation gets longer timeout
await driver.BrowsingContext.NavigateAsync(
    slowPageParams,
    TimeSpan.FromMinutes(5));

Key Takeaway: The 60-second default is intentionally set to accommodate slow operations. Choose a timeout that matches your typical use case, and use the timeoutOverride parameter on module methods when only specific commands need a different timeout.


Event Handler Synchronization

Pitfall: Not Waiting for Async Handlers to Complete

The Problem:

When using RunHandlerAsynchronously, the handler runs on a background task. Your main code might continue before the handler finishes.

// ❌ PROBLEM: Handler might not finish before program exits
driver.Network.OnBeforeRequestSent.AddObserver(
    async (e) =>
    {
        await Task.Delay(5000);  // Long-running operation
        await SaveRequestToFileAsync(e.Request);
    },
    ObservableEventHandlerOptions.RunHandlerAsynchronously
);

await driver.Session.SubscribeAsync(subscribeParams);
await driver.BrowsingContext.NavigateAsync(navParams);

// Navigation completes, but handlers might still be running!
// If program exits here, handlers may not finish

Why This Happens:

  • NavigateAsync() completes when the browser responds to the command
  • Async event handlers run independently on background tasks
  • There's no automatic synchronization between command completion and handler completion

The Solution - Option 1: Use WaitForCapturedTasksCompleteAsync (Recommended):

// ✅ GOOD: Use built-in helper
EventObserver<BeforeRequestSentEventArgs> observer =
    driver.Network.OnBeforeRequestSent.AddObserver(
        async (e) =>
        {
            await Task.Delay(5000);
            await SaveRequestToFileAsync(e.Request);
        },
        ObservableEventHandlerOptions.RunHandlerAsynchronously
    );

await driver.Session.SubscribeAsync(subscribeParams);

// Start capturing events
observer.StartCapturingTasks();

// Trigger events
await driver.BrowsingContext.NavigateAsync(navParams);

// Wait for events to occur AND handlers to complete. The result says whether the expected number
// arrived: false means the timeout elapsed first, and some handlers are still running.
bool allCompleted = await observer.WaitForCapturedTasksCompleteAsync(5, TimeSpan.FromSeconds(10));
Console.WriteLine(allCompleted ? "All handlers completed" : "Timed out waiting for handlers");

observer.StopCapturingTasks();

The Solution - Option 2: Manual Synchronization:

// ✅ GOOD: Manual synchronization for complex scenarios
EventObserver<BeforeRequestSentEventArgs> observer =
    driver.Network.OnBeforeRequestSent.AddObserver(
        async (e) =>
        {
            await SaveRequestToFileAsync(e.Request);
        },
        ObservableEventHandlerOptions.RunHandlerAsynchronously
    );

await driver.Session.SubscribeAsync(subscribeParams);

observer.StartCapturingTasks();
await driver.BrowsingContext.NavigateAsync(navParams);

// Wait for 5 events to arrive
Task[] handlerTasks = await observer.WaitForCapturedTasksAsync(5, TimeSpan.FromSeconds(10));
bool fulfilled = handlerTasks.Length == 5;

if (fulfilled)
{
    // Inspect or manipulate tasks if needed
    Console.WriteLine($"Waiting for {handlerTasks.Length} handlers to complete...");

    // Wait for all handlers to finish
    await Task.WhenAll(handlerTasks);
}

observer.StopCapturingTasks();

The Solution - Option 3: GetCapturedTasks for Custom Control:

// ✅ GOOD: GetCapturedTasks for fine-grained control
EventObserver<BeforeRequestSentEventArgs> observer =
    driver.Network.OnBeforeRequestSent.AddObserver(
        async (e) =>
        {
            await SaveRequestToFileAsync(e.Request);
        },
        ObservableEventHandlerOptions.RunHandlerAsynchronously
    );

await driver.Session.SubscribeAsync(subscribeParams);

observer.StartCapturingTasks();
await driver.BrowsingContext.NavigateAsync(navParams);

// Drain whatever tasks have arrived so far
Task[] tasks = observer.GetCapturedTasks();
await Task.WhenAll(tasks);

observer.StopCapturingTasks();

Note: GetCapturedTasks() blocks the calling thread to acquire an internal reader lock. If a concurrent WaitForCapturedTasksAsync() call holds the lock, GetCapturedTasks() will block the calling thread until that wait completes, times out, or the observer is disposed. In environments with a single-threaded SynchronizationContext (such as WPF or legacy ASP.NET), that block ties up the only thread available to the context, so prefer WaitForCapturedTasksAsync() there.

Key Takeaway: With async handlers, use WaitForCapturedTasksCompleteAsync() or WaitForCapturedTasksAsync() with manual task management to ensure handlers complete before your code continues.


Transport Error Behavior

Pitfall: Not Understanding Default Error Handling

The Problem:

By default, transport-level errors (invalid protocol messages, event handler exceptions) are ignored. This can hide bugs in your event handlers.

// ❌ PROBLEM: By default a handler exception is never thrown to your code
driver.Log.OnEntryAdded.AddObserver((e) =>
{
    // If this throws, nothing is thrown or collected by default
    ProcessLogEntry(e);  // Might throw
});

await driver.Session.SubscribeAsync(subscribeParams);
await driver.BrowsingContext.NavigateAsync(navParams);
// Unless something observes driver.OnEventHandlerErrorOccurred, you'll never know it threw!

Why Default is Ignore:

The library defaults to TransportErrorBehavior.Ignore to prevent event handler exceptions from disrupting automation. However, this can mask bugs during development.

The Solution - For Development: Use Terminate or Collect:

// ✅ Option 1: Terminate mode (throws on next command)
WebSocketConnection connection = new WebSocketConnection();
Transport transport = new Transport(connection);

// Change error behavior
transport.EventHandlerExceptionBehavior = TransportErrorBehavior.Terminate;
transport.ProtocolErrorBehavior = TransportErrorBehavior.Terminate;

BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30), transport);

try
{
    await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");

    // Add handler that might throw
    driver.Log.OnEntryAdded.AddObserver((e) => ProcessLogEntry(e));

    await driver.Session.SubscribeAsync(subscribeParams);

    // If a handler threw, the next command is where it surfaces
    await driver.BrowsingContext.NavigateAsync(navParams);
}
catch (AggregateException ex)
{
    // More than one error accumulated before the next command: each is an inner exception.
    Console.WriteLine($"Event handler errors: {string.Join(", ", ex.InnerExceptions.Select(inner => inner.Message))}");
}
catch (WebDriverBiDiException ex)
{
    // Exactly one error accumulated.
    Console.WriteLine($"Event handler error: {ex.Message}");
}

This also applies to exceptions from handlers using ObservableEventHandlerOptions.RunHandlerAsynchronously when those tasks are not captured by a capture session. If you instead capture handler tasks using WaitForCapturedTasksAsync() or WaitForCapturedTasksCompleteAsync(), those exceptions are owned by the returned tasks and should be observed there.

// ✅ Option 2: Collect mode (gather all errors)
WebSocketConnection connection = new WebSocketConnection();
Transport transport = new Transport(connection);

transport.EventHandlerExceptionBehavior = TransportErrorBehavior.Collect;
transport.ProtocolErrorBehavior = TransportErrorBehavior.Collect;

BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30), transport);

await driver.StartAsync("ws://localhost:9515/session/YOUR-SESSION-ID");
driver.Log.OnEntryAdded.AddObserver((e) => ProcessLogEntry(e));
await driver.Session.SubscribeAsync(subscribeParams);
await driver.BrowsingContext.NavigateAsync(navParams);

try
{
    await driver.StopAsync();
}
catch (AggregateException ex)
{
    // Check collected errors
    if (ex.InnerExceptions.Count > 0)
    {
        Console.WriteLine($"Collected {ex.InnerExceptions.Count} errors:");
        foreach (var error in ex.InnerExceptions)
        {
            Console.WriteLine($"  - {error.Message}");
        }
    }
}
finally
{
    await driver.DisposeAsync();
}

Note: With Collect, the errors are thrown only by StopAsync(). DisposeAsync() catches them, logs them at Warn level, and discards them—so await using var driver = ... on its own never surfaces collected errors. Call StopAsync() explicitly (as above) before disposal. The BIDI012 analyzer warns when it sees a Collect behavior set in a method that disposes the driver without stopping it.

The Solution - For Production: Handle Exceptions in Handlers:

// ✅ BEST: Handle exceptions inside handlers
driver.Log.OnEntryAdded.AddObserver((e) =>
{
    try
    {
        ProcessLogEntry(e);
    }
    catch (Exception ex)
    {
        // Log error, but don't let it propagate
        Console.WriteLine($"Error processing log entry: {ex.Message}");
    }
});

Error Behavior Modes:

Mode Behavior Best For
Ignore (default) Errors neither collected nor thrown; still reported through the diagnostic events (see Ignore Mode) Production (with try-catch in handlers)
Collect Errors stored in list; thrown by StopAsync() only (discarded by DisposeAsync()) Development, diagnostics
Terminate Throws on next command Development, fast failure

Key Takeaway: Default error behavior is Ignore. During development, use Terminate or Collect mode to catch handler bugs. In production, handle exceptions within your handlers.


Thread Safety

Pitfall: Assuming All Operations Are Thread-Safe

The Problem:

While many operations in WebDriverBiDi.NET are thread-safe, not all concurrent scenarios are supported.

What IS Thread-Safe:

  • BiDiDriver.RegisterModule()
  • Command execution (ExecuteCommandAsync)
  • Event observer notification
  • Adding and removing observers (AddObserver, RemoveObserver, Unobserve) on the same event
  • Transport message processing
  • EventObserver capture API (StartCapturingTasks, StopCapturingTasks, WaitForCapturedTasksAsync, WaitForCapturedTasksCompleteAsync, GetCapturedTasks) - individually thread-safe; only one capture session per observer at a time; the channel queues captured tasks so that no events are missed even if they arrive before the consumer is ready

What to Be Careful With:

  • Modifying shared state from multiple event handlers
  • Concurrent access to command parameter objects

The Solution:

// ✅ GOOD: Register modules before concurrent operations
driver.RegisterModule(module1);
driver.RegisterModule(module2);
await driver.StartAsync(url);

// ✅ GOOD: Add observers sequentially during setup
driver.Log.OnEntryAdded.AddObserver(handler1);
driver.Log.OnEntryAdded.AddObserver(handler2);

// ✅ GOOD: Execute commands concurrently (this IS safe)
Task<NavigateCommandResult> nav1 =
    driver.BrowsingContext.NavigateAsync(params1);
Task<NavigateCommandResult> nav2 =
    driver.BrowsingContext.NavigateAsync(params2);
await Task.WhenAll(nav1, nav2);

// ✅ GOOD: Use locks for shared state in handlers
object stateLock = new();
int counter = 0;

driver.Log.OnEntryAdded.AddObserver((e) =>
{
    lock (stateLock)
    {
        counter++;
    }
});

Key Takeaway: Transport processing, command execution, and observer registration/removal are thread-safe. You can parallelize those operations, but still protect any shared mutable application state used by handlers.


Resource Cleanup

Pitfall: Not Disposing Observers and Driver

The Problem:

Event observers and the driver hold resources that should be properly disposed.

// ❌ BAD: No cleanup
BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));
await driver.StartAsync(url);

EventObserver<EntryAddedEventArgs> observer =
    driver.Log.OnEntryAdded.AddObserver(handler);

// ... use driver ...

// Oops! Never stopped driver or removed observer
// Resources leaked!

The Solution:

Always clean up resources:

// ✅ GOOD: Proper cleanup
BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));

try
{
    await driver.StartAsync(url);

    EventObserver<EntryAddedEventArgs> observer =
        driver.Log.OnEntryAdded.AddObserver(handler);

    try
    {
        await driver.Session.SubscribeAsync(subscribeParams);

        // ... use driver ...
    }
    finally
    {
        // Remove observer when done
        observer.Unobserve();
    }
}
finally
{
    // Always stop driver
    if (driver.IsStarted)
    {
        await driver.StopAsync();
    }
}
// ✅ BETTER: Use async disposal
await using BiDiDriver driver = new BiDiDriver(TimeSpan.FromSeconds(30));
await driver.StartAsync(url);

using EventObserver<EntryAddedEventArgs> observer =
    driver.Log.OnEntryAdded.AddObserver(handler);

// Automatic cleanup when scope exits

Key Takeaway: Use try-finally blocks or using/await using statements to ensure proper cleanup of observers and the driver.


Summary Checklist

Before running your WebDriverBiDi.NET code, verify:

Tip: Add the WebDriverBiDi.Analyzers package to get compile-time diagnostics for many of these pitfalls.

  • [ ] Event handlers use RunHandlerAsynchronously for I/O operations
  • [ ] You've called both AddObserver() AND Session.SubscribeAsync()
  • [ ] Modules, custom events, and type resolvers registered BEFORE StartAsync(); observers added BEFORE Session.SubscribeAsync()
  • [ ] Nullable collections (Headers/Cookies on the network continue/provide commands, and the ClientHintsMetadata brand and form-factor lists) checked for null before adding items; every other list populated with .Add() or a collection initializer
  • [ ] Timeout configured appropriately for your use case
  • [ ] Async handlers synchronized with capture API if needed
  • [ ] Transport error behavior set for development/production
  • [ ] Using correct WebSocket URL format (ws://)
  • [ ] Thread safety considered for concurrent operations
  • [ ] Resources properly cleaned up with try-finally or using statements

See Also

Next Steps