Skip to main content
The SDK runs in the browser — you can wrap client-side LLM calls in a contract and ship events to Boundary or your own endpoint. The runtime is more constrained than Node, so defaults differ.

Critical caveat — API keys

Never ship your Boundary API key to the browser. Bundle-time env vars like NEXT_PUBLIC_*, VITE_*, and REACT_APP_* are visible in page source and devtools. Either:
  1. Keep contracts server-side. Call your server, have the server invoke the contract + logger, and return the result. This is the recommended path.
  2. Use a custom write that POSTs to your authenticated server. The server then forwards to Boundary with the real key.
// client-side — no apiKey, no direct Boundary traffic
const logger = createBoundaryLogger({
  async write(events) {
    await fetch("/api/boundary-proxy", {
      method: "POST",
      body: JSON.stringify(events),
      credentials: "include",
    });
  },
});
Then in /api/boundary-proxy, authenticate the user and forward to Boundary with the server-side key. You get browser-origin telemetry without exposing the key.

What the SDK does on the browser

With flushOnExit: true (default), the SDK attaches two listeners:
  • visibilitychange — fires flush() when document.visibilityState becomes "hidden" (tab switched away, phone screen locked, tab about to be killed).
  • pagehide — fires flush() when the page is being unloaded. Covers cases where beforeunload doesn’t fire (mobile Safari, navigating away from bfcache).
Neither attaches to beforeunload specifically — mobile browsers are inconsistent about firing it, and pagehide is the modern guidance.

Best-effort only

If the browser is terminating the tab (user force-quits, OS kills the process), the flush has no guarantee of completing. The SDK uses whatever time the browser grants before the page goes away. For events you must not lose, ship them server-side.

Tighten the capture policy

The browser bundle is user-visible. Never opt into inputs: true or outputs: true in a client-side logger — the raw prompt and completion become part of data the user’s browser receives over the wire, and then sends back.
createBoundaryLogger({
  write,
  capture: {
    inputs: false,
    outputs: false,
    repairs: true,
    errors: true,
    metadata: true,
  },
  // redact anything a rule could have interpolated
  redact: {
    patterns: [
      /[\w.+-]+@[\w-]+\.[\w.-]+/,
      /\b\d{3}-\d{2}-\d{4}\b/,
    ],
  },
});

sendBeacon for final flush

The SDK uses fetch under the hood, which can be aborted mid-flight by page navigation. A write sink that uses navigator.sendBeacon instead is more reliable for the final flush:
const logger = createBoundaryLogger({
  write(events) {
    const blob = new Blob([JSON.stringify(events)], { type: "application/json" });
    // Works in pagehide / visibilitychange handlers where fetch() often cancels.
    const ok = navigator.sendBeacon("/api/boundary-proxy", blob);
    if (!ok) {
      // Falls back to fetch if the beacon queue is full.
      return fetch("/api/boundary-proxy", {
        method: "POST",
        body: blob,
        keepalive: true,
      }).then(() => undefined);
    }
  },
});
sendBeacon is fire-and-forget — no response, no retry. That’s fine for telemetry but means you can’t assert delivery.

Single-page apps

Keep the logger at module scope. A route change inside an SPA is not a page unload, so the logger survives naturally. The visibilitychange / pagehide listeners only fire on actual tab/page lifecycle events.
// src/boundary.ts
import { createBoundaryLogger } from "@withboundary/sdk";

export const logger = createBoundaryLogger({
  write: sendToProxy,
  batch: {
    size: 10,
    intervalMs: 2000,   // faster flush — browsers die more often than servers
    maxQueueSize: 100,
  },
});
Lower maxQueueSize than Node defaults — memory pressure matters more in the browser, and dropping old events on an unload storm is acceptable.

Web Workers

The SDK runs in Web Workers (Dedicated or Shared). Neither document nor window is available, so the visibilitychange / pagehide listeners don’t attach — the SDK silently feature-detects. You drain explicitly:
// worker.ts
self.addEventListener("message", async (e) => {
  const result = await contract.accept((attempt) => runLLM(attempt));
  self.postMessage(result);
  await logger?.flush(500);
});

Streaming LLM responses

If your client streams from the model (SSE / fetch streaming), the contract still runs after the stream completes — RunFn returns the assembled string. There’s no special browser wiring; the contract + logger operates the same way as in Node.

See also

Custom sinks

The write() pattern for proxying via your server

Capture policy

Why raw inputs/outputs must stay off client-side