Skip to main content

Building a Better OOPIF

· 16 min read
Yoav
Founder @Blackboard Technologies Inc.

This post dives deep into the architecture of OOPIFs and the evolution of Electrobun’s implementation of the <electrobun-webview> (a “super iframe”) HTML tag. We’ll explore why standard iframes fall short in desktop-like applications and how Electrobun’s approach addresses those gaps with a more flexible, secure, and performant solution.

What Is an OOPIF?

An OOPIF (Out Of Process IFrame) is an iframe-like element that behaves as a fully independent webview but is still controlled and positioned by the host webview’s DOM. This separation enables greater security, isolation, and performance; making OOPIFs a fundamental building block for complex desktop applications built on web technologies.

Electrobun’s <electrobun-webview> tag is our OOPIF implementation. It powers apps like co(lab), a hybrid code editor and browser, where each tab uses <electrobun-webview> to load and manage remote content securely and in isolation, all while integrating seamlessly with the rest of the app.

Why Not Use a Regular Iframe?

For typical websites, regular iframes are restricted to mitigate security risks (like cross-site scripting). Modern browsers often prevent iframes from loading cross-domain content to protect users’ data.

However, in desktop-like apps built with frameworks such as Electron, Tauri, or Electrobun, these inherent iframe restrictions can be too limiting. You often need full control and flexibility while maintaining a strong security boundary. OOPIFs solve this dilemma by granting both the isolation of a separate process and the convenient positioning of an iframe.

The <webview> Tag in Chrome and Electron

Google Chrome’s <webview> Tag

As part of its Chrome Apps platform, Google introduced the <webview> tag to embed fully isolated webviews in web apps. It was an excellent solution for building browser-like experiences inside an app without the constraints of iframes.

Over time, however, enabling <webview> in Chromium required command-line flags, and the feature never became standard for the broader web. In 2020, Google deprecated the Chrome Apps platform, and <webview> support is slated to end in January 2025.

warning

Note: The <webview> tag is scheduled to be removed from Chromium in January 2025.

Electron’s Webview Tag

Electron merges Node.js and Chromium, allowing you to write Node.js and browser code in a single runtime. This includes maintaining Electron’s own patches to Chromium—among them, the soon-to-be-removed <webview> tag. With its deprecation looming, it’s unclear how the Electron team will adapt post-January 2025.

Several GitHub issues, such as this one and this one, highlight the uncertainty. The Electron community must either maintain its own implementation or pivot to a new solution.

Electrobun’s OOPIF Implementation

Key Requirements

Electrobun’s approach to OOPIFs aims to provide the following:

  • DOM Positioning: The <electrobun-webview> element should behave like any other DOM element, so developers can easily style, animate, and position it.
  • Isolation: Each OOPIF must be fully isolated from other OOPIFs for security and performance.
  • Inter-process Communication (IPC): Fast and efficient communication between the Bun process, the host webview, and the OOPIF webview (including native event handling) is essential.
  • Performance: Minimizing resource consumption while maintaining a smooth user experience.
  • Layering and Transparency: OOPIFs should support layering and transparency effects without breaking the host webview’s design.
  • Cross-platform Support: Should work with system webviews (WebKit on macOS, Chromium/Edge on Windows, WebKitGTK on Linux) and bundled engines (Servo or Chromium).
  • No Source Modifications: Avoid patching web engines at the source level.
  • Easy Migration from Electron: Tools like co(lab) should migrate seamlessly to Electrobun, retaining tab management, security, partitions, and lifecycle events.
info

In summary: <electrobun-webview> must match or exceed Electron’s <webview> capabilities while remaining engine-agnostic.

Because OOPIFs are central to Electrobun’s multi-webview strategy, building them right from the start was critical. Our focus on isolation, performance, and flexibility shapes how developers will build next-generation desktop apps using web technologies.

info

Up next: We’ll walk through the various architectures and mechanisms we tried before landing on our final OOPIF solution.

Initial Architecture

Electrobun’s architecture comprises three core components:

  1. Main Process (Bun): Your TypeScript code runs here, leveraging Electrobun’s API (e.g., opening native windows, creating webviews).
  2. Native Wrapper (Zig/Obj-C on macOS): An internal layer handling system-level details, bridging between Bun, native APIs, and the browser engine.
  3. Browser API: Runs in the webview context, enabling <electrobun-webview> and other high-level features.

For example:

// src/bun/index.ts — main process code
import { BrowserWindow } from "electrobun/bun";

const win = new BrowserWindow({
title: "my url window",
url: "views://mainview/index.html",
frame: {
width: 1800,
height: 600,
x: 2000,
y: 2000,
},
});
// src/bun/mainview/index.ts — browser process code
// Transpiled into views://mainview/index.js by Electrobun CLI
import { Electroview } from "electrobun/view";

// Initialize Electrobun's browser API, making
// the electrobun-webview element available
new Electroview({});
<!-- src/mainview/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webview tag</title>
<script src="views://webviewtag/index.js"></script>
</head>
<body>
<!-- Load an Electrobun OOPIF (Out Of Process IFrame) -->
<electrobun-webview
src="http://wikipedia.org"
partition=""
></electrobun-webview>
</body>
</html>

When you open a BrowserWindow via the Bun API, under the hood it calls the native wrapper to open a native window and create the main webview. Once your browser HTML runs, each <electrobun-webview> initializes another webview effectively functioning as an OOPIF.

How Does <electrobun-webview> Work?

Fundamentally, <electrobun-webview> is a custom HTML element that:

  1. Signals the native wrapper to create another webview process.
  2. Uses DOM APIs (like getBoundingClientRect) to track its size and position, relaying these to the native code so the isolated webview can be placed in the correct spot on screen.

In other words; <electrobun-webview> is an anchor element in the DOM that references and controls a separate, out-of-process webview.

Early Implementation and Challenges

Resizing and Positioning

We need to use getBoundingClientRect on the anchor element. Then serialize the measurement, send it to objc via zig, deserialize it, and apply it to the native webview layered above the host to give the illusion that the OOPIFs content is in the host's DOM.

Initially the OOPIF html anchor element used a mix of observers and listeners to try and react to changes in the elements position and dimensions to take the measurement.

Unfortunately in WebKit (macOS’s default engine) there is no layout-shift performanceObserver we can subscribe to so you have to proactively measure the anchor for changes.

Make Animations Buttery Smooth

If there's any lag between the html anchor moving or resizing, and updating the webview's dimensions in objc it's quite noticeable and breaks the illusion. So you would want a high-frequency interval at 30+ frames per second, but calling getBoundingClientRect is expensive and doing so very often for lots of OOPIFs causes significant performance issues. To balance this we initially set up a low-freqency interval to check every OOPIF anchor for dimension changes.

This would make it "eventually consistent" (under a second) while not running the measurement loop so frequently that you'd run into performance issues with lots of OOPIFs.

When a change in the anchor's dimensions or position is detected that specific OOPIF would then go into a temporary overdrive mode with a shorter interval where it measured itself more frequently.

This solves for actual performance by avoiding running the measurement for all OOPIFs on the page too frequently, and perceived performance by sending specific OOPIFs into overdrive mode during animations to keep ahead of the actual rendering frame-rate.

We then exposed an overdrive method so that when you're about to move the OOPIFs anchor or animate its dimensions you can manually trigger the overdrive mode to eliminate the initial lag in that automatic overdrive mechanism.

Layering Issues

Now let's remember that our OOPIF implementation is effectively just a native window with multiple native webviews inside it. The main (host) webview is fit to the window dimensions and your OOPIF webviews are fit to their corresponding html anchors inside the host webview. They're effectively just layered on top in a way that makes them feel as though they're just html elements.

This results in two major headaches:

  1. DOM Overlays: Host elements (like a dropdown menu) that should visually appear over the OOPIF would end up hidden beneath it because the OOPIF's contents are a separate native layer.
  2. Cursor Flicker: A hyperlink in the OOPIF might want a pointer (hand) cursor, while the anchor in the host webview underneath wants a default (arrow) cursor — resulting in constant flickering as both try to update the system cursor.
  3. Drag and Drop: Drag and Drop operations are interrupted at the boundaries of overlaying webviews only considering top-most webview layer.

Mouse Passthrough

The first attempt at solving for DOM Overlays was to implement mouse passthrough. Take a screenshot of the OOPIF's rendered content, send that image to the host, set the background of the anchor to screenshot image, then move the actual webview out the way. Now the overlay element on the host only has to be above the OOPIF's anchor but visually it looked like it was above the content, and mouse interactions naturally worked with the overlay element.

In this iteration you needed to manually toggle passthrough mode in the host webview's context.

We couldn't really automate this well as you'd need to make the overlay non-intersecting (ie: hide the dropdown) before making the OOPIF interactive again by moving it back into position.

A challenge also arises when you have something like an animation or video playing in the OOPIF webview, taking snapshots at 30 frames per second to make it not feel like a static snapshot, sending those images to the host and updating the background image is very cpu intensive. I have an external display that's over 5000px wide so you're sending large images across the RPC wire.

There were lots of attempts to make the image conversion and serialization more performant, there was actually a lot of innovation there that unlocked and remains powering unrelated functionality, but in the end another approach to solving this issue would be needed.

Cursor Nightmare

This cursor issue nearly drove me to insanity. It was so minor and so significant at the same time. Would a flickering cursor really derail my goal of making Electrobun a viable desktop app framework?

My initial attempts at solving this sent me digging through webkit code and objc event bubbling, writing hit test workarounds in objc and all kinds things. But alas the default MacOS webview WKWebKit does not bubble cursor changes in a way that can be intercepted this way.

Eventually I came up with a way to at least reduce the flicker.

In objc I overrode the application's cursor change method directly using a technique called swizzling. I kept track of the last 20 cursors in an array and if any of them were not the default then the most recent non-default cursor would be applied.

While this seems like it would "just work" for most cases while looking through WebKits's source I realized that it only sets the native cursor when it's different. So if you have two webview layers (host and OOPIF) and you let the OOPIF set a hand cursor and block the next 20 default cursor changes from the underlying anchor element, WKWebkit won't keep switching between setting default/pointer, it'll instead see that it's currently pointer and only try set it to default.

Put a different way you end up with an array of 1 pointer (hand) cursor and 19 default (arrow) changes in a row before another pointer (hand) change shows up.

So this approach effectively reduced the flicker from every pixel to one flicker every 20px of movement which was mostly not perceptable to the human eye. In addition it also created a lag when mouseOut a link and doesn't scale to more complex layering or non-default cursor scenarios. I knew I would have to come up with a better solution eventually.

My commit message at the time captured my feelings about it "disgusting hack for cursor flicker when hovering over <electrobun-webview> html elements,".

Drag and Drop

While cursor changes will trigger for all the layered webviews's contents under the cursor, Drag and Drop behaviour stops at the edges of a webview. This breaks many Drag and Drop use cases.

For example in co(lab) you can set up vertical and horizontal panes like a code editor and have browser tabs in each one. When you drag the pane divider you expect to resize all the pane contents but since your mouse has to move resizing the panes before the OOPIF anchors can be re-measured; your mouse can easily move over the OOPIF's webcontent faster than the OOPIF can move out of the way — effectively eating the drag operation.

The initial approach to this issue was the same as other layering issues, which was to use the screenshot passthrough approach described above. As you drag the pane dividers you mouse moves over the anchors (with screenshots for backgrounds) and the Drag operation doesn't get eaten. For a few panes and tabs at small sizes this worked well, but was very visible and performance intensive when the co(lab) window was large or when tabs were playing videos and needed high refresh rates. The dimensions of the anchor could also change before the screenshot arrived causing stretching and artifacts with the now mismatched screenshot dimensions.

A More Performant Layering Solution

I stumbled on a neat trick during some thinky time one day. Instead of the current approach to layering that involved syncing screenshots to the host DOM and "disgusting" cursor hacks, I realized that on MacOS a WKWebview was constructed of multiple layers and that the "paint" layer that contains the final rendered pixel data of a webview was a separate movable rect to the interactive layer where the DOM is.

Similar to how I was moving the entire webview out of the way and putting a screenshot in the anchor I could just use the render-layer of the webview itself which is already basically an image and just move it out of the way.

In the new regime there would be no screenshots. All the webviews (the host and the OOPIFs) would be moved 20,000 pixels off-screen. Their render layers would be positioned in the main area and layered appropriately.

All the logic that was being done in the host webview context for managing passthrough and so on was moved into objc. I added mouse move listener to the native window and as the mouse moved over the layered webviews the interactive layer for the correct webview was moved in place and all the others moved 20,000px off-screen.

This approach was basically the inverse of my original approach with fewer moving parts. It solved for the cursor flicker because only one interactive layer was under the mouse at a given time, and solved most layering issues like resizing pane dividers without the performance hit of syncing screenshots. It was elegant, but it still lacked support for overlays.

Support Overlays

For overlays (menus or elements in the host webview's DOM that should appear layered above the OOPIF's webview) screenshotting was still needed. Until I had a bit more thinky time where I realized that because the rendered layer of each webview was essentially an image, you could do image-related things to it like make some of the pixels transparent. It just so happens MacOS provides methods for creating image masks which punch a visual hole through an image.

So I updated the <electrobun-webview> custom element to support giving it a list of css selectors. Whenever the position of the anchor was calculated it would also look for elements with those selectors getting their dimensions (let's call those rectangles masks) and it would send both the anchor and mask dimensions to objc. When objc positions the OOPIF's webview it would now create layer masks with the masks' dimensions. For added performance instead of doing any intersection calculations myself to create a complex mask it would just add them blindly letting the gpu do the work.

The other piece was to update the mousemove code which now lives in objc and which decides which webview's interactive layer to move in place to account for the masks.

The result is that you can give a <electrobun-webview> element a bunch of css selectors like an .overlays class selector. Then just use the overlays classname on elements that should appear over the webview and it automatically magically works.

Because there are no screenshots synced to the host, no cursor hacks, and so on even if you have multiple large OOPIFS all playing video, with all kinds of overlayed elements, it all just works at native speed.

Scrolling Edge Case

An bonus edge case for this post that might be interesting was that sometimes when scrolling the host webview an OOPIF webview could get scrolled under the mouse cursor. Since the logic to swap out which interactive layer is "active" and in place was in the mousemove event, anything other than mouse moves that caused a different webview to be under the mouse cursor (like scrolling or animations) wouldn't trigger a change in the active webview. If you clicked or scrolled again without moving the mouse those events go to the wrong webview.

So we needed to add code to keep track of the "active webview" based on the current mouse position, and it whenever the dimensions of any OOPIF were updated so that the correct interactive layer could be moved into position even when the mouse isn't moved.

Room For Improvement

The biggest remaining manual step is triggering “overdrive mode” for smooth resizing and positioning updates. If you don’t do it, the default interval will still catch changes—just not instantly. Ideally, we’d automate this entirely, possibly via performance observers in Chromium-based engines when we add Windows support.

Conclusion

Electrobun’s OOPIF design addresses the core issues with regular iframes while avoiding the soon-to-be-removed <webview> in Chromium. We’ve taken a multi-iteration path to conquer layering, cursor synchronization, and performance concerns, resulting in a more robust, engine-agnostic approach for building desktop apps with web technologies.

Whether you’re porting an Electron app or creating something fresh, <electrobun-webview> aims to combine the security of an out-of-process architecture with the seamless integration of a standard DOM element. From layering tricks to smooth animations, we’re striving to make building full-featured, multi-webview desktop experiences more accessible—and we’re just getting started.

Stay tuned for further deep dives into Electrobun’s internals, and feel free to join our community to share feedback, questions, or showcase your own Electrobun-powered projects!

Meet Electrobun

· 3 min read
Yoav
Founder @Blackboard Technologies Inc.

Earlier this year I was just wrapping up a beta of co(lab) a hybrid web browser + code editor for deep work. Mostly written in Typescript, SolidJS, and Tailwind. I'd been building it in Electron and after figuring out how to get code signing and notarization working, how to distribute it and so on I ended up with a distributable that was 80MB+ compressed.

I'd added an update mechanism as well (from yet another separate project) that was supposed to be the gold standard that uses blockmap technology. This segments your next version into blocks and lets you only download the blocks you need when an update is available. It does this on the uncompressed version of your app which in my case was over 150MB+. However if you happen to make a change that affects a part of your app at the beginning, even if it's only a single character, then all the blocks will shift with different checksums meaning each of your users will likely have to download almost 150MB.

I started multiplying 150MB by the number of users and multiplying that by the number of times I wanted to ship an update. I wanted to ship like the web—continuously—but the sheer amount of data I would have to distribute with every small app change was obsene. I'm bootstrapping Blackboard and I found myself having to hold back on updates and ship less frequently to avoid spending hundreds of dollars in S3/Cloudfront bandwith costs.

I had been kicking around the idea of building an Electron alternative with Bun since long before Bun hit their 1.0.0 and having to second guess how often I ship while trying to bootstrap a startup lab was just the motivation I needed.

So I picked up zig, C, C++, and Objc and got to work. Once I'd built enough functionality into Electrobun I ported co(lab) from Electron to Electrobun. The compressed distributable for the Electrobun version of co(lab) is only 18MB. That's more than a 4x reduction, but I wasn't satisfied with that. I ported BSDIFF from C to zig, optimizing it for speed with SIMD and some other tricks I came up with and added a built-in update mechanism in Electrobun. Now when users download updates they only need to download a patch file. A small copy change now generates a 14KB patch file. So from up to 150MB with Electron's blockmaps to 14KB patch file that's a 95% reduction in the size of updates.

After 20 years at small startups and unicorns, and spending so much time earlier in my career with Adobe Flex/AIR, being able to ship desktop apps in Typescript with a batteries included framework at the speed of the web is just the thing I've always wanted.

Next steps for Electrobun are to upgrade the Bun and Zig versions, support distributing your apps to Windows and Linux, and enable more functionality like shipping a cross-platform web renderer for developers that prioritize rendering consistency over initial download size, instead of relying on the default setting of using system's webview.

Technically Electrobun is the first thing I'm shipping from my startup lab Blackboard. I hope you like it as much as I do.