diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionReaper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionReaper.cs
new file mode 100644
index 00000000..cffe77a6
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionReaper.cs
@@ -0,0 +1,67 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
+
+///
+/// Background service that periodically evicts idle browse sessions from the
+/// . Each tick takes a snapshot of the registry,
+/// removes any session that has been idle longer than , then
+/// disposes the evicted instance OUTSIDE the dictionary — so concurrent expand
+/// calls racing eviction fail cleanly via .
+///
+public sealed class BrowseSessionReaper(
+ BrowseSessionRegistry registry,
+ ILogger logger) : BackgroundService
+{
+ /// How long a session may be untouched before it becomes eligible for eviction.
+ public static readonly TimeSpan IdleTtl = TimeSpan.FromMinutes(2);
+
+ /// How often the reaper checks the registry.
+ public static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(30);
+
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ using var timer = new PeriodicTimer(TickInterval);
+ while (!stoppingToken.IsCancellationRequested &&
+ await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
+ {
+ try { await ReapOnceAsync(stoppingToken).ConfigureAwait(false); }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Browse-session reaper iteration failed; will retry next tick.");
+ }
+ }
+ await DrainAllAsync().ConfigureAwait(false);
+ }
+
+ /// Evicts every session whose
+ /// is older than . Internal so tests can drive a tick directly.
+ internal async Task ReapOnceAsync(CancellationToken ct)
+ {
+ var now = DateTime.UtcNow;
+ foreach (var (token, session) in registry.Snapshot())
+ {
+ if (now - session.LastUsedUtc < IdleTtl) continue;
+ if (!registry.TryRemove(token, out var taken)) continue;
+ try { await taken.DisposeAsync().ConfigureAwait(false); }
+ catch (Exception ex)
+ {
+ logger.LogDebug(ex,
+ "Best-effort dispose of idle-evicted browse session {Token} failed.", token);
+ }
+ logger.LogDebug("Browse session {Token} closed reason=idle-ttl", token);
+ }
+ }
+
+ private async Task DrainAllAsync()
+ {
+ foreach (var (token, session) in registry.Snapshot())
+ {
+ if (!registry.TryRemove(token, out var taken)) continue;
+ try { await taken.DisposeAsync().ConfigureAwait(false); } catch { }
+ logger.LogDebug("Browse session {Token} closed reason=shutdown", token);
+ }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionRegistry.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionRegistry.cs
new file mode 100644
index 00000000..ca6feee9
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionRegistry.cs
@@ -0,0 +1,30 @@
+using System.Collections.Concurrent;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
+
+///
+/// Singleton in-process directory of live instances,
+/// keyed by . Concurrency is provided by the
+/// underlying alone — there are no
+/// additional locks; callers must dispose evicted sessions outside the dictionary.
+///
+public sealed class BrowseSessionRegistry
+{
+ private readonly ConcurrentDictionary _sessions = new();
+
+ /// Adds (or replaces) a session in the registry keyed by its token.
+ public void Register(IBrowseSession session) => _sessions[session.Token] = session;
+
+ /// Looks up a session by token without removing it.
+ public bool TryGet(Guid token, out IBrowseSession session) =>
+ _sessions.TryGetValue(token, out session!);
+
+ /// Atomically removes a session from the registry, returning it for disposal.
+ public bool TryRemove(Guid token, out IBrowseSession session) =>
+ _sessions.TryRemove(token, out session!);
+
+ /// Returns a point-in-time snapshot of all currently registered sessions.
+ public IReadOnlyList<(Guid Token, IBrowseSession Session)> Snapshot() =>
+ _sessions.Select(kv => (kv.Key, kv.Value)).ToList();
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs
new file mode 100644
index 00000000..60ac40b4
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs
@@ -0,0 +1,72 @@
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
+
+///
+/// Default implementation. Indexes injected
+/// s by
+/// (case-insensitive) at construction, registers opened sessions, and wraps each
+/// expand/attributes call in a 20-second linked CTS so a stuck driver cannot
+/// stall the UI indefinitely.
+///
+public sealed class BrowserSessionService(
+ IEnumerable browsers,
+ BrowseSessionRegistry registry,
+ ILogger logger) : IBrowserSessionService
+{
+ /// Upper bound on a single root/expand/attributes call.
+ public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
+
+ private readonly IReadOnlyDictionary _browsersByType =
+ browsers.ToDictionary(b => b.DriverType, StringComparer.OrdinalIgnoreCase);
+
+ ///
+ public async Task OpenAsync(string driverType, string configJson, CancellationToken ct)
+ {
+ if (!_browsersByType.TryGetValue(driverType, out var browser))
+ return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty);
+ try
+ {
+ var session = await browser.OpenAsync(configJson, ct).ConfigureAwait(false);
+ registry.Register(session);
+ return new(true, null, session.Token);
+ }
+ catch (Exception ex)
+ {
+ logger.LogInformation(ex,
+ "Browser open failed for driverType={DriverType}: {Message}", driverType, ex.Message);
+ return new(false, ex.Message, Guid.Empty);
+ }
+ }
+
+ ///
+ public Task> RootAsync(Guid token, CancellationToken ct) =>
+ InvokeAsync(token, ct, (s, c) => s.RootAsync(c));
+
+ ///
+ public Task> ExpandAsync(Guid token, string nodeId, CancellationToken ct) =>
+ InvokeAsync(token, ct, (s, c) => s.ExpandAsync(nodeId, c));
+
+ ///
+ public Task> AttributesAsync(Guid token, string nodeId, CancellationToken ct) =>
+ InvokeAsync>(token, ct, (s, c) => s.AttributesAsync(nodeId, c));
+
+ ///
+ public async Task CloseAsync(Guid token)
+ {
+ if (!registry.TryRemove(token, out var session)) return;
+ try { await session.DisposeAsync().ConfigureAwait(false); } catch { }
+ logger.LogDebug("Browse session {Token} closed reason=user-close", token);
+ }
+
+ private async Task InvokeAsync(
+ Guid token, CancellationToken callerCt, Func> op)
+ {
+ if (!registry.TryGet(token, out var session))
+ throw new BrowseSessionNotFoundException(token);
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(callerCt);
+ cts.CancelAfter(PerCallTimeout);
+ return await op(session, cts.Token).ConfigureAwait(false);
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs
new file mode 100644
index 00000000..b7b171bd
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs
@@ -0,0 +1,48 @@
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
+
+///
+/// Outcome of . On success
+/// is and is the
+/// registry handle; on failure carries a human-readable
+/// diagnostic for the UI's error chip.
+///
+/// True iff the browse session was opened and registered.
+/// Failure diagnostic, or on success.
+/// Registry handle on success; on failure.
+public sealed record BrowseOpenResult(bool Ok, string? Message, Guid Token);
+
+///
+/// Scoped Razor-page facade over the in-process browse-session machinery. Owns
+/// driver-type dispatch on open and per-call timeout enforcement on expand/attributes.
+///
+public interface IBrowserSessionService
+{
+ /// Opens a session against the named driver type using the given JSON config.
+ /// Never throws — all errors are surfaced via .
+ Task OpenAsync(string driverType, string configJson, CancellationToken ct);
+
+ /// Returns the root nodes of an open session. Throws
+ /// if the token is unknown.
+ Task> RootAsync(Guid token, CancellationToken ct);
+
+ /// Returns the direct children of in an open session.
+ /// Throws if the token is unknown.
+ Task> ExpandAsync(Guid token, string nodeId, CancellationToken ct);
+
+ /// Returns the attributes of in an open session. Throws
+ /// if the token is unknown.
+ Task> AttributesAsync(Guid token, string nodeId, CancellationToken ct);
+
+ /// Removes the session from the registry and disposes it. No-op for unknown tokens.
+ Task CloseAsync(Guid token);
+}
+
+///
+/// Raised by the service layer when a caller references a token that is not
+/// (or no longer) in the registry — typically because the reaper evicted it
+/// between calls.
+///
+public sealed class BrowseSessionNotFoundException(Guid token)
+ : InvalidOperationException($"Browse session {token} not found (may have been reaped).");