Bun.WebView: Zero-Dependency Headless Browser Automation

Bun v1.3.12 introduces Bun.WebView, a native headless browser automation API built directly into the runtime. No external dependencies, no npm packages to install — just the Bun binary and a few lines of code to control a web browser programmatically.

This represents a significant milestone for the JavaScript ecosystem: for the first time, developers can automate browsers without pulling in hundreds of megabytes of Puppeteer or Playwright dependencies. Bun ships with two backend options out of the box:

  • WebKit (macOS default) — Uses the system WKWebView, zero external dependencies
  • Chrome (cross-platform) — Chrome/Chromium via DevTools Protocol, auto-detects installed browsers

Getting Started

The API is designed for simplicity. Create a WebView instance, navigate to a page, interact with it, and take screenshots:

await using view = new Bun.WebView({ width: 800, height: 600 });
await view.navigate("https://bun.sh");

await view.click("a[href='/docs']");
await view.scroll(0, 400);
await view.scrollTo("#install");

const title = await view.evaluate("document.title");
const screenshot = await view.screenshot({ format: "jpeg", quality: 90 });
await Bun.write("page.jpg", screenshot);

The await using syntax ensures the browser process is properly cleaned up when the scope exits, thanks to Bun's support for the Explicit Resource Management proposal (TC39).

Native Input Events with isTrusted: true

One of the most distinctive features of Bun.WebView is how it handles user interactions. All input is dispatched as OS-level events — websites cannot distinguish view.click() from a real mouse click:

await view.click("#login-button"); // isTrusted: true in the browser
await view.type("hello@bun.sh"); // native keyboard events
await view.press("Enter", { modifiers: ["Shift"] }); // key + modifier combo

This is fundamentally different from Puppeteer and Playwright, which simulate events at the JavaScript level. Those tools can be detected by websites checking event.isTrusted. Bun's native approach bypasses this detection entirely, making it ideal for:

  • Testing real user interactions
  • Scraping sites with bot detection
  • Automating flows that require "trusted" events
  • Validating accessibility with real focus management

Actionability Waiting (Playwright-Style)

Selector-based methods like click() and scrollTo() automatically wait for actionability before executing. The element must be:

  1. Attached — Present in the DOM
  2. Visible — Not hidden by CSS
  3. Stable — Not animating or moving
  4. Unobscured — No other element overlaying it
// This waits up to 30 seconds for the element to become clickable
await view.click("#submit-button");

// Scroll to an element, waiting for it to become visible
await view.scrollTo("#comments-section");

This eliminates the need for manual waitForSelector calls that plague Puppeteer code:

// Puppeteer requires manual waiting
await page.waitForSelector("#button", { visible: true });
await page.click("#button");

// Bun.WebView does it automatically
await view.click("#button");

Cross-Backend Compatibility

All methods work identically across both WebKit and Chrome backends:

MethodDescription
navigate(url)Navigate to a URL
evaluate(expr)Execute JavaScript in the page context
screenshot({format, quality, encoding})Capture PNG/JPEG/WebP screenshots
click(x, y) / click(selector)Click at coordinates or selector
type(text)Type text into the focused element
press(key, {modifiers})Press a key with modifiers
scroll(dx, dy) / scrollTo(selector)Scroll by delta or to element
goBack() / goForward() / reload()Navigation controls
resize(w, h)Resize viewport dimensions
cdp(method, params)Raw Chrome DevTools Protocol call
view.url / view.title / view.loadingPage state properties

Choosing a Backend

On macOS, WebKit is the default because it's already installed as part of the operating system. For cross-platform compatibility or when you need Chrome-specific features:

// Use Chrome explicitly
await using view = new Bun.WebView({
  width: 800,
  height: 600,
  backend: "chrome"
});

// Custom Chrome path
await using view = new Bun.WebView({
  backend: { type: "chrome", path: "/usr/bin/chromium-browser" }
});

The Chrome backend auto-detects installed browsers (Chrome, Chromium, Edge) and falls back gracefully if none are found.

Capturing Console Output

Debugging browser automation is easier when you can see what the page is logging:

await using view = new Bun.WebView({
  console: true // Capture page console.log calls
});

await view.navigate("https://example.com");
// Page console output appears in your terminal

Or capture logs programmatically:

const logs: string[] = [];

await using view = new Bun.WebView({
  console: (message) => logs.push(message)
});

Persistent Profiles with dataStore

For scenarios requiring logged-in sessions or persisted state:

await using view = new Bun.WebView({
  width: 800,
  height: 600,
  dataStore: "./browser-profile" // Persistent cookies, localStorage, etc.
});

// Login once, session persists across runs
await view.navigate("https://dashboard.example.com/login");
await view.type("#email", "user@example.com");
await view.type("#password", "secret");
await view.click("#login");

// Next run: already logged in

Event Handling via EventTarget

Bun.WebView extends EventTarget, allowing you to listen for browser events:

await using view = new Bun.WebView({ backend: "chrome" });

view.addEventListener("message", (event) => {
  // CDP events dispatched as MessageEvent
  console.log("CDP event:", event.data);
});

await view.navigate("https://example.com");

On the Chrome backend, Chrome DevTools Protocol (CDP) events are automatically dispatched as MessageEvents with the params accessible via event.data.

Direct CDP Access

For advanced use cases, call Chrome DevTools Protocol methods directly:

await using view = new Bun.WebView({ backend: "chrome" });

// Intercept network requests
await view.cdp("Network.enable", {});
await view.cdp("Network.setRequestInterception", { enabled: true });

view.addEventListener("message", (event) => {
  if (event.data.method === "Network.requestIntercepted") {
    console.log("Intercepted:", event.data.params.request.url);
  }
});

Process Sharing and Tabs

One browser subprocess is shared per Bun process. Additional new Bun.WebView() calls open tabs in the same browser instance rather than spawning new processes:

// All three share one browser process
const view1 = new Bun.WebView();
const view2 = new Bun.WebView();
const view3 = new Bun.WebView();

await view1.navigate("https://bun.sh");
await view2.navigate("https://github.com");
await view3.navigate("https://npmjs.org");

This is more efficient than Puppeteer's default behavior, which spawns a new browser for each launch() call unless you explicitly reuse the browser instance.

Practical Example: Web Scraping

Here's a complete scraping workflow that demonstrates the API:

await using view = new Bun.WebView({ width: 1280, height: 720 });

await view.navigate("https://news.ycombinator.com");
await view.waitForLoading();

// Get all article titles and links
const articles = await view.evaluate(`
  Array.from(document.querySelectorAll('.titleline > a'))
    .slice(0, 10)
    .map(a => ({ title: a.textContent, url: a.href }))
`);

console.log(articles);

// Save a screenshot for debugging
const screenshot = await view.screenshot({ format: "png" });
await Bun.write("hn-frontpage.png", screenshot);

Practical Example: Form Automation

Automating a login flow with real keyboard input:

await using view = new Bun.WebView({
  width: 800,
  height: 600,
  dataStore: "./auth-profile"
});

await view.navigate("https://app.example.com/login");

// Focus the email field (actionability wait included)
await view.click("#email");
await view.type("developer@bun.sh");

// Move to password field
await view.click("#password");
await view.type("my-secret-password");

// Submit with Enter key
await view.press("Enter");

// Wait for navigation to complete
await view.waitForLoading();

console.log("Logged in! Current URL:", view.url);

Node.js Comparison

To achieve headless browser automation in Node.js, you need to install and configure Puppeteer or Playwright — both are heavy dependencies:

Node.js with Puppeteer

npm install puppeteer
# Downloads ~170MB Chromium binary automatically
import puppeteer from "puppeteer";

const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.goto("https://bun.sh");

// Puppeteer events are simulated, not trusted
await page.click("a[href='/docs']");

// Manual waiting required
await page.waitForSelector("#some-element");
await page.click("#some-element");

// Evaluate JavaScript
const title = await page.evaluate(() => document.title);

// Screenshot
await page.screenshot({ path: "page.png" });

await browser.close();

Bun with Bun.WebView

# No installation needed — it's built into Bun
bun run script.ts
await using view = new Bun.WebView({ width: 800, height: 600 });

await view.navigate("https://bun.sh");

// Native trusted events
await view.click("a[href='/docs']");

// Automatic actionability waiting
await view.click("#some-element");

// Evaluate JavaScript
const title = await view.evaluate("document.title");

// Screenshot
const png = await view.screenshot({ format: "png" });
await Bun.write("page.png", png);

// Auto-cleanup with `await using`

Dependency Comparison

AspectBun.WebViewPuppeteerPlaywright
InstallationNone (built-in)npm install (~170MB download)npm install (~150MB download)
RuntimeBun binary (~35MB)Node.js + bundled ChromiumNode.js + bundled browsers
Total disk usage~35MB~200MB+~180MB+
Trusted eventsYes (isTrusted: true)No (simulated)No (simulated)
Actionability waitAutomaticManual (waitForSelector)Automatic
Backend optionsWebKit + ChromeChromium onlyChromium, Firefox, WebKit

Performance Considerations

Bun.WebView on macOS with WebKit has zero browser download overhead because it uses the system's built-in WKWebView. Startup time is faster than Puppeteer's bundled Chromium because no large binary needs to be unpacked and initialized.

For CI/CD pipelines, the Chrome backend auto-detects installed browsers, meaning you can use whatever Chromium is available in your Docker image without configuring paths manually.

When to Use Bun.WebView

Ideal Use Cases

  • Automated testing — Real browser interactions for E2E tests
  • Web scraping — Sites with bot detection requiring trusted events
  • Screenshot generation — Capture page states for documentation
  • Form automation — Login flows, data entry, form submissions
  • Accessibility testing — Real focus management and keyboard navigation

Consider Alternatives When

  • You need Firefox support (Playwright is better)
  • You're on Node.js and can't switch to Bun
  • You require Playwright's advanced tracing/debugging features
  • Your project already has extensive Puppeteer/Playwright integration

Conclusion

Bun.WebView marks a paradigm shift in browser automation for JavaScript. By embedding headless browser control directly into the runtime, Bun eliminates the dependency bloat that has characterized this space for years. The native OS-level input events provide genuine isTrusted behavior that Puppeteer and Playwright cannot replicate.

For Bun developers, this means one less external dependency to manage, faster startup times, and a simpler API that handles actionability waiting automatically. The dual-backend design ensures cross-platform compatibility while still leveraging macOS's native WebKit where available.

As the Bun ecosystem continues to expand its built-in toolkit, Bun.WebView joins a growing suite of zero-dependency APIs — from the SQL client to Redis, cron scheduling, shell scripting, and Markdown parsing. The vision of an "all-in-one toolkit" for JavaScript development continues to materialize.

Resources