Flow Browser uses Electron’s IPC (Inter-Process Communication) system for communication between the main process (Node.js) and renderer processes (React UI).
IPC Architecture
Three-Layer System
Layer 1: Renderer (React Components)
React components call the Flow API:
// Renderer process - React component
function TabControls () {
const handleNewTab = async () => {
// Call Flow API exposed by preload
await flow . tabs . newTab ( 'https://example.com' );
};
return < button onClick = { handleNewTab } > New Tab </ button > ;
}
Layer 2: Preload (Context Bridge)
File : src/preload/index.ts:410
Preload exposes the Flow API via contextBridge:
// Preload script
import { contextBridge , ipcRenderer } from 'electron' ;
const tabsAPI : FlowTabsAPI = {
newTab : async ( url ?: string , isForeground ?: boolean , spaceId ?: string ) => {
return ipcRenderer . invoke ( 'tabs:new-tab' , url , isForeground , spaceId );
},
onDataUpdated : ( callback : ( data : WindowTabsData ) => void ) => {
return listenOnIPCChannel ( 'tabs:on-data-changed' , callback );
},
closeTab : async ( tabId : number ) => {
return ipcRenderer . invoke ( 'tabs:close-tab' , tabId );
}
};
// Expose to renderer
contextBridge . exposeInMainWorld ( 'flow' , {
tabs: wrapAPI ( tabsAPI , 'browser' ),
// ... other APIs
});
Security : The preload script runs in an isolated context and carefully controls which APIs are exposed. Only explicitly defined methods are available to the renderer.
Layer 3: Main Process (IPC Handlers)
File : src/main/ipc/browser/tabs.ts:1
Main process registers IPC handlers:
// Main process IPC handlers
import { ipcMain } from 'electron' ;
import { tabsController } from '@/controllers/tabs-controller' ;
ipcMain . handle ( 'tabs:new-tab' , async ( event , url ?: string , isForeground ?: boolean , spaceId ?: string ) => {
const sender = event . sender ;
const window = windowsController . getWindowFromWebContents ( sender );
if ( ! window || window . type !== 'browser' ) {
throw new Error ( 'Invalid window context' );
}
const tab = await tabsController . createTab (
window . id ,
undefined , // Use default profile
spaceId ,
undefined ,
{ url , isForeground }
);
return tab . id ;
});
IPC Patterns
Request/Response (invoke/handle)
For operations that return values:
// Async request with response
const tabId = await flow . tabs . newTab ( 'https://example.com' );
console . log ( 'Created tab:' , tabId );
Fire-and-Forget (send)
For operations that don’t need a response:
// No return value needed
flow . navigation . goTo ( 'https://example.com' , tabId );
Event Subscriptions (on)
For receiving updates from main process:
// Subscribe to tab updates
useEffect (() => {
const unsubscribe = flow . tabs . onDataUpdated (( data ) => {
setTabsData ( data );
});
return unsubscribe ; // Cleanup
}, []);
IPC Channel Organization
Directory : src/main/ipc/
IPC handlers are organized by domain:
App
Browser
Session
Window
Location : src/main/ipc/app/Application-level handlers:
app.ts - App info, clipboard, default browser
window-controls.ts - Window minimize/maximize/close
updates.ts - Auto-update functionality
shortcuts.ts - Keyboard shortcut management
extensions.ts - Extension management
new-tab.ts - New tab creation
icons.ts - App icon management
onboarding.ts - Onboarding flow
open-external.ts - External link handling
actions.ts - App-wide actions
Location : src/main/ipc/browser/Browser functionality:
browser.ts - Browser window creation, profile loading
tabs.ts - Tab CRUD operations
page.ts - Page bounds and layout
navigation.ts - URL navigation, reload, history
interface.ts - UI component positioning
find-in-page.ts - Find in page functionality
Location : src/main/ipc/session/Profile and workspace management:
profiles.ts - Profile CRUD operations
spaces.ts - Space (workspace) management
Location : src/main/ipc/window/Window-specific:
settings.ts - Settings window IPC
omnibox.ts - Omnibox window IPC
Type-Safe IPC
Flow Browser uses shared TypeScript interfaces for type safety across IPC boundaries.
Interface Definitions
Directory : src/shared/flow/interfaces/
Each API domain has a typed interface:
// src/shared/flow/interfaces/browser/tabs.ts
export interface FlowTabsAPI {
getData () : Promise < WindowTabsData >;
onDataUpdated ( callback : ( data : WindowTabsData ) => void ) : () => void ;
onTabsContentUpdated ( callback : ( tabs : TabData []) => void ) : () => void ;
switchToTab ( tabId : number ) : Promise < void >;
closeTab ( tabId : number ) : Promise < void >;
moveTab ( tabId : number , newPosition : number ) : Promise < void >;
newTab ( url ?: string , isForeground ?: boolean , spaceId ?: string ) : Promise < number >;
getRecentlyClosed () : Promise < PersistedTabData []>;
restoreRecentlyClosed ( uniqueId : string ) : Promise < void >;
}
Shared Types
Directory : src/shared/types/
// src/shared/types/tabs.ts
export interface TabData {
id : number ;
uniqueId : string ;
profileId : string ;
spaceId : string ;
url : string ;
title : string ;
favicon : string | null ;
isLoading : boolean ;
canGoBack : boolean ;
canGoForward : boolean ;
visible : boolean ;
asleep : boolean ;
position : number ;
groupId : string | null ;
}
export interface WindowTabsData {
windowId : number ;
currentSpaceId : string ;
tabs : TabData [];
tabGroups : TabGroupData [];
activeTabIds : WindowActiveTabIds ;
focusedTabIds : WindowFocusedTabIds ;
profiles : string [];
spaces : string [];
}
Shared types ensure that the data structure sent from main process matches what renderer expects - TypeScript catches mismatches at compile time.
Permission System
The preload script implements a permission system to control API access.
File : src/preload/index.ts:58
type Permission = 'all' | 'app' | 'browser' | 'session' | 'settings' ;
function hasPermission ( permission : Permission ) : boolean {
const isFlowProtocol = isProtocol ( 'flow:' );
const isFlowInternalProtocol = isProtocol ( 'flow-internal:' );
// Browser UI
const isMainUI = isLocation ( 'flow-internal:' , 'main-ui' );
const isPopupUI = isLocation ( 'flow-internal:' , 'popup-ui' );
switch ( permission ) {
case 'all' :
return true ;
case 'app' :
return isFlowInternalProtocol || isFlowProtocol ;
case 'browser' :
return isMainUI || isPopupUI ;
case 'session' :
return isFlowInternalProtocol ;
case 'settings' :
return isFlowInternalProtocol ;
default :
return false ;
}
}
API Wrapping
APIs are wrapped with permission checks:
function wrapAPI < T >( api : T , permission : Permission , overrides ?: {}) {
const wrappedAPI = {} as T ;
for ( const key in api ) {
const value = api [ key ];
if ( typeof value === 'function' ) {
wrappedAPI [ key ] = ( ... args ) => {
const requiredPermission = overrides ?.[ key ] || permission ;
if ( ! hasPermission ( requiredPermission )) {
throw new Error ( `Permission denied: flow. ${ permission } . ${ key } ()` );
}
return value ( ... args );
};
}
}
return wrappedAPI ;
}
// Expose with permissions
const flowAPI = {
tabs: wrapAPI ( tabsAPI , 'browser' , {
newTab: 'app' , // Special: newTab allowed for all internal pages
disablePictureInPicture: 'all' // Special: allowed everywhere
}),
// ...
};
Security Critical : The permission system prevents arbitrary web pages from accessing browser APIs. Only Flow’s internal pages (flow: and flow-internal: protocols) can call these APIs.
Event Broadcasting
Main process controllers emit events that trigger IPC messages to all relevant windows.
Broadcasting Pattern
// Controller emits event
tabsController . on ( 'tab-created' , ( tab ) => {
windowTabsChanged ( tab . getWindow (). id );
});
tabsController . on ( 'tab-removed' , ( tab ) => {
windowTabsChanged ( tab . getWindow (). id );
});
// IPC handler broadcasts to window
export function windowTabsChanged ( windowId : number ) {
const window = browserWindowsController . getWindowById ( windowId );
if ( ! window || quitController . isQuitting ) return ;
const data = getWindowTabsData ( window );
// Send to main browser window
window . browserWindow . webContents . send ( 'tabs:on-data-changed' , data );
// Send to portal windows (omnibox, etc.)
for ( const portal of window . portalWindows . values ()) {
portal . webContents . send ( 'tabs:on-data-changed' , data );
}
}
File : src/main/ipc/browser/tabs.ts:189
Lightweight Updates
For frequent updates (URL changes, titles), use content-only updates:
export function windowTabContentChanged ( windowId : number , tabId : number ) {
const window = browserWindowsController . getWindowById ( windowId );
if ( ! window || quitController . isQuitting ) return ;
const tab = tabsController . getTabById ( tabId );
if ( ! tab ) return ;
const managers = tabsController . getTabManagers ( tabId );
const tabData = serializeTabForRenderer ( tab , managers ?. lifecycle . preSleepState );
// Only send the changed tab, not all tabs
const payload = [ tabData ];
window . browserWindow . webContents . send ( 'tabs:on-tabs-content-updated' , payload );
for ( const portal of window . portalWindows . values ()) {
portal . webContents . send ( 'tabs:on-tabs-content-updated' , payload );
}
}
File : src/main/ipc/browser/tabs.ts:224
Optimization : windowTabContentChanged only sends the changed tab data, while windowTabsChanged sends the full window state. Use content-only updates for high-frequency changes like URL/title updates.
Listener Management
Flow implements automatic listener cleanup to prevent memory leaks.
File : src/main/ipc/listeners-manager.ts
class ListenersManager {
private listeners : Map < string , Set < string >> = new Map ();
public addListener ( webContentsId : number , channel : string , listenerId : string ) {
const key = ` ${ webContentsId } : ${ channel } ` ;
const listeners = this . listeners . get ( key ) || new Set ();
listeners . add ( listenerId );
this . listeners . set ( key , listeners );
}
public removeListener ( webContentsId : number , channel : string , listenerId : string ) {
const key = ` ${ webContentsId } : ${ channel } ` ;
const listeners = this . listeners . get ( key );
if ( listeners ) {
listeners . delete ( listenerId );
if ( listeners . size === 0 ) {
this . listeners . delete ( key );
}
}
}
}
// IPC handlers
ipcMain . on ( 'listeners:add' , ( event , channel , listenerId ) => {
listenersManager . addListener ( event . sender . id , channel , listenerId );
});
ipcMain . on ( 'listeners:remove' , ( event , channel , listenerId ) => {
listenersManager . removeListener ( event . sender . id , channel , listenerId );
});
This ensures listeners are properly tracked and cleaned up when components unmount.
WebAuthn/Passkeys IPC
Flow implements native passkey support via custom IPC handlers.
Files :
src/main/ipc/webauthn/index.ts
src/preload/index.ts:103 (passkey patching)
// Preload patches navigator.credentials
navigator . credentials . create = async ( options ) => {
if ( options ?. publicKey ) {
const result = await ipcRenderer . invoke ( 'webauthn:create' , options );
return WebauthnUtils . mapCredentialRegistrationResult ( result );
}
return originalCreate ( options );
};
navigator . credentials . get = async ( options ) => {
if ( options ?. publicKey ) {
const result = await ipcRenderer . invoke ( 'webauthn:get' , options );
return WebauthnUtils . mapCredentialAssertResult ( result );
}
return originalGet ( options );
};
This transparent patching allows websites to use standard WebAuthn APIs, which Flow intercepts and implements using the electron-webauthn native module.
Only send necessary data: // Bad: Send entire tab object
webContents . send ( 'tab-updated' , fullTabObject );
// Good: Send only changed properties
webContents . send ( 'tab-title-changed' , { id: tab . id , title: tab . title });
Combine multiple updates into single message: // Bad: Multiple IPC messages
for ( const tab of tabs ) {
webContents . send ( 'tab-updated' , tab );
}
// Good: Single message with array
webContents . send ( 'tabs-updated' , tabs );
Debounce High-Frequency Events
Use debouncing for events that fire rapidly: // In renderer
const debouncedUpdate = debounce (( url ) => {
flow . navigation . goTo ( url );
}, 300 );
input . addEventListener ( 'input' , ( e ) => {
debouncedUpdate ( e . target . value );
});
For frequent property changes, use lightweight update channels: // Full update: sends all tabs, groups, active state (heavy)
windowTabsChanged ( windowId );
// Content update: sends only changed tab properties (light)
windowTabContentChanged ( windowId , tabId );
Debugging IPC
Enable IPC logging:
// In main process
import { debugPrint } from '@/modules/output' ;
ipcMain . handle ( 'tabs:new-tab' , async ( event , ... args ) => {
debugPrint ( 'IPC' , 'tabs:new-tab' , args );
// ...
});
Monitor IPC messages in DevTools:
// In renderer console
require ( 'electron' ). ipcRenderer . on ( 'tabs:on-data-changed' , ( event , data ) => {
console . log ( 'Tab data updated:' , data );
});
Next Steps
Main Process Dive deeper into main process controllers
Renderer Process Learn more about the React UI layer