Type-safe, high-performance inter-process communication for Electron applications
Electron Direct IPC provides direct renderer-to-renderer communication via MessageChannel, bypassing the main process for improved performance and reduced latency. It's modeled after Electron's ipcRenderer/ipcMain API for familiarity. With full TypeScript support (including send/receive and invoke/handle message/argument/return types), automatic message coalescing, and a clean API, it's designed for real-time applications that need fast, reliable IPC.
import) and CommonJS (require)npm install electron-direct-ipc
// main.ts
import { DirectIpcMain } from 'electron-direct-ipc/main'
DirectIpcMain.init()
// That's it! DirectIpcMain handles all the coordination automatically
// types.ts
type MyMessages = {
'user-action': (action: 'play' | 'pause' | 'stop', value: number) => void
'position-update': (x: number, y: number) => void
'volume-change': (level: number) => void
}
type MyInvokes = {
'get-user': (userId: string) => Promise<{ id: string; name: string }>
calculate: (a: number, b: number) => Promise<number>
}
type WindowIds = 'controller' | 'output' | 'thumbnails'
// controller-renderer.ts
import { DirectIpcRenderer } from 'electron-direct-ipc/renderer'
// This adds the types to the singleton instance
// and sets this renderer's identifier to 'controller'
const directIpc = DirectIpcRenderer.instance<MyMessages, MyInvokes, WindowIds>({
identifier: 'controller',
})
// Send a message with multiple arguments
await directIpc.send({ identifier: 'output' }, 'user-action', 'play', 42)
// Send high-frequency updates (throttled)
for (let i = 0; i < 1000; i++) {
directIpc.throttled.send({ identifier: 'output' }, 'position-update', i, i)
}
// Throttling coalesces - only the last position (999, 999) is actually sent!
// output-renderer.ts
import { DirectIpcRenderer } from 'electron-direct-ipc/renderer'
const directIpc = DirectIpcRenderer.instance<MyMessages, MyInvokes, WindowIds>({
identifier: 'output',
})
// Listen for messages
directIpc.on('user-action', (sender, action, value) => {
console.log(`${sender.identifier} sent action: ${action} with value: ${value}`)
})
// Listen for high-frequency updates (throttled)
directIpc.throttled.on('position-update', (sender, x, y) => {
// Called at most once per microtask with latest values
updateUI(x, y)
})
// Handle invoke requests
directIpc.handle('get-user', async (sender, userId) => {
const user = await database.getUser(userId)
return { id: user.id, name: user.name }
})
// Invoke another renderer
const result = await directIpc.invoke({ identifier: 'controller' }, 'calculate', 5, 10)
console.log(result) // 15
DirectIPC uses the Web MessageChannel API for direct communication between renderers. The main process coordinates the initial connection, then gets out of the way:
Renderer A Main Process Renderer B
| | |
|---GET_PORT('output')---->| |
| creates MessageChannel and distributes ports |
|<----PORT_MESSAGE---------| |
| |-----PORT_MESSAGE------->|
| | |
|<=============MessageChannel established===========>|
| | |
|-----------------------messages-------------------->|
|<----------------------messages---------------------|
(main process not involved)
DirectIPC uses a TargetSelector object to specify which renderer(s) to communicate with. This provides a clean, consistent API across all send and invoke operations.
Single Target Selectors - Send to one renderer (throws if multiple match):
// By identifier (best for readability)
await directIpc.send({ identifier: 'output' }, 'play')
// By webContentsId (best for precision)
await directIpc.send({ webContentsId: 5 }, 'play')
// By URL pattern (best for dynamic windows)
await directIpc.send({ url: /^settings:\/\// }, 'theme-changed', 'dark')
Multi-Target Selectors - Broadcast to all matching renderers:
// Send to all renderers matching identifier pattern
await directIpc.send({ allIdentifiers: /^output/ }, 'broadcast-message', 'data')
// Send to all renderers matching URL pattern
await directIpc.send({ allUrls: /^settings:/ }, 'theme-changed', 'dark')
Note: The invoke() method only supports single-target selectors, as it expects a single response.
DirectIPC provides two communication modes:
Non-Throttled (default) - Every message is delivered
// Every message sent
directIpc.send({ identifier: 'output' }, 'button-clicked')
Throttled - Only latest value per microtask is delivered (lossy)
// Only the last value (999) is sent
for (let i = 0; i < 1000; i++) {
directIpc.throttled.send({ identifier: 'output' }, 'position', i)
}
DirectIPC uses TypeScript generics to ensure compile-time type safety:
// Define your types
type Messages = {
'position-update': (x: number, y: number) => void
}
// Get full autocomplete and type checking
directIpc.send({ identifier: 'output' }, 'position-update', 10, 20) // โ
directIpc.send({ identifier: 'output' }, 'position-update', '10', '20') // โ Type error!
directIpc.send({ identifier: 'output' }, 'wrong-channel', 10, 20) // โ Type error!
// Listeners are also type-safe
directIpc.on('position-update', (sender, x, y) => {
// x and y are inferred as numbers
const sum: number = x + y // โ
})
Main class for renderer process communication.
// Singleton pattern (recommended)
const directIpc = DirectIpcRenderer.instance<TMessages, TInvokes, TIdentifiers>({
identifier: 'my-window',
log: customLogger,
})
// For testing (creates new instance)
const directIpc = DirectIpcRenderer._createInstance<TMessages, TInvokes, TIdentifiers>(
{ identifier: 'test-window' },
{ ipcRenderer: mockIpcRenderer }
)
// Unified send method with TargetSelector
await directIpc.send<K extends keyof TMessages>(
target: TargetSelector<TIdentifiers>,
message: K,
...args: Parameters<TMessages[K]>
): Promise<void>
// TargetSelector types:
type TargetSelector<TId extends string> =
| { identifier: TId | RegExp } // Single target by identifier
| { webContentsId: number } // Single target by webContentsId
| { url: string | RegExp } // Single target by URL
| { allIdentifiers: TId | RegExp } // Broadcast to all matching identifiers
| { allUrls: string | RegExp } // Broadcast to all matching URLs
// Examples:
await directIpc.send({ identifier: 'output' }, 'play')
await directIpc.send({ webContentsId: 5 }, 'play')
await directIpc.send({ url: /^settings:/ }, 'theme-changed', 'dark')
await directIpc.send({ allIdentifiers: /^output/ }, 'broadcast', 'data')
await directIpc.send({ allUrls: /^settings:/ }, 'update', 'value')
// Register listener
directIpc.on<K extends keyof TMessages>(
event: K,
listener: (sender: DirectIpcTarget, ...args: Parameters<TMessages[K]>) => void
): this
// Remove listener
directIpc.off<K extends keyof TMessages>(
event: K,
listener: Function
): this
// Register handler (receiver)
directIpc.handle<K extends keyof TInvokes>(
channel: K,
handler: (sender: DirectIpcTarget, ...args: Parameters<TInvokes[K]>) => ReturnType<TInvokes[K]>
): void
// Unified invoke method with TargetSelector (single targets only)
const result = await directIpc.invoke<K extends keyof TInvokes>(
target: Omit<TargetSelector<TIdentifiers>, 'allIdentifiers' | 'allUrls'>,
channel: K,
...args: [...Parameters<TInvokes[K]>, options?: InvokeOptions]
): Promise<Awaited<ReturnType<TInvokes[K]>>>
// Examples:
const result = await directIpc.invoke({ identifier: 'output' }, 'calculate', 5, 10)
const result = await directIpc.invoke({ webContentsId: 5 }, 'getData')
const result = await directIpc.invoke({ url: /^output/ }, 'process', data)
// With timeout option
const result = await directIpc.invoke(
{ identifier: 'output' },
'calculate',
5,
10,
{ timeout: 5000 }
)
// Get current renderer map
directIpc.getMap(): DirectIpcTarget[]
// Get this renderer's identifier
directIpc.getMyIdentifier(): TIdentifiers | undefined
// Set this renderer's identifier
directIpc.setIdentifier(identifier: TIdentifiers): void
// Resolve target to webContentsId
directIpc.resolveTargetToWebContentsId(target: {
webContentsId?: number
identifier?: TIdentifiers | RegExp
url?: string | RegExp
}): number | undefined
// Refresh renderer map
await directIpc.refreshMap(): Promise<void>
// Configure timeout
directIpc.setDefaultTimeout(ms: number): void
directIpc.getDefaultTimeout(): number
// Clean up
directIpc.closeAllPorts(): void
directIpc.clearPendingInvokes(): void
// Listen for internal DirectIpc events
directIpc.localEvents.on('target-added', (target: DirectIpcTarget) => {})
directIpc.localEvents.on('target-removed', (target: DirectIpcTarget) => {})
directIpc.localEvents.on('map-updated', (map: DirectIpcTarget[]) => {})
directIpc.localEvents.on('message-port-added', (target: DirectIpcTarget) => {})
directIpc.localEvents.on('message', (sender: DirectIpcTarget, message: unknown) => {})
Accessed via directIpc.throttled property. Provides lossy message coalescing for high-frequency updates.
โ Use throttled when:
โ Don't use throttled when:
All send/receive methods available, same signatures as DirectIpcRenderer:
// Send throttled
directIpc.throttled.send({ identifier: 'output' }, 'position', x, y)
directIpc.throttled.send({ webContentsId: 5 }, 'volume', level)
directIpc.throttled.send({ url: /output/ }, 'progress', percent)
// Receive throttled
directIpc.throttled.on('position', (sender, x, y) => {
// Called at most once per microtask
})
// Proxy methods (non-throttled)
directIpc.throttled.handle('get-data', async (sender, id) => data)
await directIpc.throttled.invoke({ identifier: 'output' }, 'calculate', a, b)
// Access underlying directIpc
directIpc.throttled.directIpc.send({ identifier: 'output' }, 'important-event')
// Access localEvents
directIpc.throttled.localEvents.on('target-added', (target) => {})
Send-side coalescing:
// In one event loop tick:
directIpc.throttled.send({ identifier: 'output' }, 'position', 1, 1)
directIpc.throttled.send({ identifier: 'output' }, 'position', 2, 2)
directIpc.throttled.send({ identifier: 'output' }, 'position', 3, 3)
// Only position (3, 3) is sent on next microtask (~1ms later)
// Messages to different targets/channels are NOT coalesced
Receive-side coalescing:
// Renderer receives many messages in one tick
// Internal handler queues them all
// Listeners called once per microtask with latest values
directIpc.throttled.on('position', (sender, x, y) => {
console.log(x, y) // Only prints latest value
})
Coordinates MessageChannel connections between renderers.
import { DirectIpcMain } from 'electron-direct-ipc/main'
// Create singleton instance
const directIpcMain = new DirectIpcMain({
log: customLogger, // optional
})
// That's it! DirectIpcMain automatically:
// - Tracks all renderer windows
// - Creates MessageChannels when renderers request connections
// - Broadcasts map updates when windows open/close
// - Cleans up when windows close
No manual coordination needed! DirectIpcMain handles everything automatically.
For communication with Electron UtilityProcess workers. Use utility processes for CPU-intensive tasks that would block the renderer.
1. Create a utility process worker:
// utility-worker.ts
import { DirectIpcUtility } from 'electron-direct-ipc/utility'
// Define message types (same pattern as renderer)
type WorkerMessages = {
'compute-result': (result: number) => void
progress: (percent: number) => void
}
type WorkerInvokes = {
'heavy-computation': (numbers: number[]) => Promise<number>
'get-stats': () => Promise<{ uptime: number; processed: number }>
}
// Create instance with identifier
const utility = DirectIpcUtility.instance<WorkerMessages, WorkerInvokes>({
identifier: 'compute-worker',
})
// Listen for messages from renderers
utility.on('compute-request', async (sender, data) => {
const result = performHeavyWork(data)
await utility.send({ identifier: sender.identifier! }, 'compute-result', result)
})
// Handle invoke requests (RPC style)
utility.handle('heavy-computation', async (sender, numbers) => {
return numbers.reduce((a, b) => a + b, 0)
})
// Send high-frequency updates with throttling
utility.throttled.send({ identifier: 'main-window' }, 'progress', 50)
2. Spawn and register from main process:
// main.ts
import { utilityProcess } from 'electron'
import { DirectIpcMain } from 'electron-direct-ipc/main'
const directIpcMain = DirectIpcMain.init()
// Spawn the utility process
const worker = utilityProcess.fork('utility-worker.js')
// Register with DirectIpcMain
directIpcMain.registerUtilityProcess('compute-worker', worker)
// Handle worker exit
worker.on('exit', (code) => {
console.log(`Worker exited with code ${code}`)
})
3. Communicate from renderer:
// renderer.ts
import { DirectIpcRenderer } from 'electron-direct-ipc/renderer'
const directIpc = DirectIpcRenderer.instance({ identifier: 'main-window' })
// Send message to utility process
await directIpc.send({ identifier: 'compute-worker' }, 'compute-request', 42)
// Invoke utility process handler
const result = await directIpc.invoke(
{ identifier: 'compute-worker' },
'heavy-computation',
[1, 2, 3, 4, 5]
)
// Listen for results from utility process
directIpc.on('compute-result', (sender, result) => {
console.log(`Result from ${sender.identifier}: ${result}`)
})
// Singleton pattern (same as DirectIpcRenderer)
const utility = DirectIpcUtility.instance<TMessages, TInvokes, TIdentifiers>({
identifier: 'my-worker',
log: customLogger,
defaultTimeout: 30000,
registrationTimeout: 5000,
})
// Send messages (same API as DirectIpcRenderer)
await utility.send({ identifier: 'renderer' }, 'message-name', ...args)
await utility.send({ webContentsId: 5 }, 'message-name', ...args)
await utility.send({ allIdentifiers: /^renderer-/ }, 'broadcast', data)
// Receive messages
utility.on('channel', (sender, ...args) => {})
utility.off('channel', listener)
// Invoke/Handle pattern
utility.handle('channel', async (sender, ...args) => result)
const result = await utility.invoke({ identifier: 'target' }, 'channel', ...args)
// Throttled messaging (same API as DirectIpcRenderer)
utility.throttled.send({ identifier: 'renderer' }, 'position', x, y)
utility.throttled.on('position', (sender, x, y) => {})
// Registration lifecycle
utility.getRegistrationState() // 'uninitialized' | 'subscribing' | 'registered' | 'failed'
utility.localEvents.on('registration-complete', () => {})
utility.localEvents.on('registration-failed', (error) => {})
DirectIpcUtility goes through a registration lifecycle when starting:
| State | Description |
|---|---|
uninitialized |
Instance created, registration not started |
subscribing |
Registration sent, waiting for main process confirmation |
registered |
Ready for communication |
failed |
Registration timed out or failed |
Messages sent before registration completes are automatically queued and flushed once registered.
// Sender
await directIpc.send({ identifier: 'output' }, 'play-button-clicked')
// Receiver
directIpc.on('play-button-clicked', (sender) => {
playbackEngine.play()
})
// Sender (high-frequency updates)
videoPlayer.on('timeupdate', (currentTime) => {
directIpc.throttled.send({ identifier: 'controller' }, 'playback-position', currentTime)
})
// Receiver
directIpc.throttled.on('playback-position', (sender, position) => {
seekBar.update(position)
})
// Receiver (set up handler)
directIpc.handle('get-project-data', async (sender, projectId) => {
const project = await database.getProject(projectId)
return {
id: project.id,
name: project.name,
songs: project.songs,
}
})
// Sender (invoke handler)
try {
const project = await directIpc.invoke(
{ identifier: 'controller' },
'get-project-data',
'project-123',
{ timeout: 5000 } // 5 second timeout
)
console.log(project.name)
} catch (error) {
console.error('Failed to get project:', error)
}
// Send to all windows matching pattern
await directIpc.send({ allIdentifiers: /output-.*/ }, 'theme-changed', 'dark')
// Send to all windows with specific URL
await directIpc.send({ allUrls: /^settings:\/\// }, 'preference-updated', 'volume', 75)
// High-frequency position updates (throttled)
directIpc.throttled.on('cursor-position', (sender, x, y) => {
cursor.moveTo(x, y)
})
// Important user actions (NOT throttled)
directIpc.on('cursor-click', (sender, button, x, y) => {
handleClick(button, x, y)
})
// Handle invoke errors
directIpc.handle('risky-operation', async (sender, data) => {
if (!isValid(data)) {
throw new Error('Invalid data')
}
return await performOperation(data)
})
// Caller handles rejection
try {
const result = await directIpc.invoke({ identifier: 'worker' }, 'risky-operation', myData)
} catch (error) {
console.error('Operation failed:', error.message)
}
// Get all available renderers
const targets = directIpc.getMap()
console.log(
'Available windows:',
targets.map((t) => t.identifier)
)
// Listen for new windows
directIpc.localEvents.on('target-added', (target) => {
console.log(`New window: ${target.identifier}`)
// Send welcome message
directIpc.send({ webContentsId: target.webContentsId }, 'welcome')
})
// Listen for windows closing
directIpc.localEvents.on('target-removed', (target) => {
console.log(`Window closed: ${target.identifier}`)
})
// Main process - spawn and register utility worker
import { utilityProcess } from 'electron'
import { DirectIpcMain } from 'electron-direct-ipc/main'
const directIpcMain = DirectIpcMain.init()
const worker = utilityProcess.fork('heavy-worker.js')
directIpcMain.registerUtilityProcess('heavy-worker', worker)
// Utility process - handle CPU-intensive work
import { DirectIpcUtility } from 'electron-direct-ipc/utility'
const utility = DirectIpcUtility.instance({ identifier: 'heavy-worker' })
utility.handle('process-data', async (sender, data) => {
// Heavy computation runs in isolated process
const result = expensiveOperation(data)
return result
})
// Send progress updates back to renderer
utility.throttled.send({ identifier: 'main-window' }, 'progress', 75)
// Renderer - offload work to utility process
const result = await directIpc.invoke(
{ identifier: 'heavy-worker' },
'process-data',
largeDataset,
{ timeout: 30000 }
)
// Listen for progress from utility process
directIpc.throttled.on('progress', (sender, percent) => {
updateProgressBar(percent)
})
Use regular directIpc for:
Use directIpc.throttled for:
| Method | Latency | Delivery | Use Case |
|---|---|---|---|
directIpc.send* |
~0ms | Guaranteed | Events, commands |
directIpc.throttled.send* |
~1ms | Lossy (latest only) | State updates |
directIpc.invoke* |
~1-5ms | Guaranteed | RPC calls |
DirectIPC is designed to be lightweight:
On a modern laptop (M1 MacBook):
| Metric | Result |
|---|---|
| Send 1000 non-throttled messages | ~8ms |
| Send 1000 throttled messages | ~0.5ms (only 1 delivered) |
| Invoke round-trip | ~0.05ms average |
| Connect new renderer | ~25ms (includes MessageChannel setup) |
| Throughput | ~23,000 invokes/sec |
The real benefit of DirectIPC is renderer-to-renderer communication. Traditional Electron IPC requires routing through the main process:
Traditional: renderer1 โ ipcMain โ renderer2 (relay pattern)
DirectIPC: renderer1 โ renderer2 (direct MessageChannel)
| Operation | Traditional IPC | DirectIPC | Speedup |
|---|---|---|---|
| Send 1000 msgs (r1 โ r2) | ~50ms | ~4ms | ~14x faster |
| Invoke round-trip (r1 โ r2) | ~0.10ms | ~0.05ms | ~2x faster |
Note: For renderer โ main communication only (no relay), traditional ipcRenderer.invoke is slightly faster (~0.05ms) since it doesn't involve MessageChannel overhead. DirectIPC shines when you need direct renderer-to-renderer or renderer-to-utility communication.
Run npm run test:e2e:benchmark to validate these on your machine.
DirectIPC includes comprehensive test utilities.
import { DirectIpcRenderer } from 'electron-direct-ipc/renderer'
import { describe, it, expect, vi } from 'vitest'
describe('My component', () => {
it('should send message when button clicked', async () => {
const mockIpcRenderer = {
on: vi.fn(),
invoke: vi.fn().mockResolvedValue([]),
}
const directIpc = DirectIpcRenderer._createInstance(
{ identifier: 'test' },
{ ipcRenderer: mockIpcRenderer as any }
)
// Spy on send method
const spy = vi.spyOn(directIpc, 'send').mockResolvedValue()
// Test your code
await myComponent.onClick()
expect(spy).toHaveBeenCalledWith('output', 'play-clicked')
})
})
See DirectIpc.integration.test.ts for examples of testing full renderer-to-renderer communication with MessageChannel.
The project includes 21 comprehensive E2E tests covering window-to-window communication, throttled messaging, and page reload scenarios. See tests/e2e/example.spec.ts for the full test suite.
import { test, expect } from '@playwright/test'
import { _electron as electron } from 'playwright'
test('renderer communication', async () => {
const app = await electron.launch({ args: ['main.js'] })
// Get windows
const controller = await app.firstWindow()
const output = await app.waitForEvent('window')
// Trigger action in controller
await controller.click('#play-button')
// Verify message received in output
const isPlaying = await output.evaluate(() => {
return window.playbackState === 'playing'
})
expect(isPlaying).toBe(true)
await app.close()
})
graph TB
subgraph Main["Main Process"]
DIM[DirectIpcMain
โข Tracks all renderer windows
โข Tracks utility processes
โข Creates MessageChannels on demand
โข Broadcasts map updates]
end
subgraph RendererA["Renderer A (e.g., Controller)"]
DIRA[DirectIpcRenderer
โข Message sending
โข Event receiving
โข Invoke/handle]
THROT_A[.throttled
โข Coalescing]
DIRA --- THROT_A
end
subgraph RendererB["Renderer B (e.g., Output)"]
DIRB[DirectIpcRenderer
โข Message sending
โข Event receiving
โข Invoke/handle]
THROT_B[.throttled
โข Coalescing]
DIRB --- THROT_B
end
subgraph UtilityProc["Utility Process (e.g., Worker)"]
DIRU[DirectIpcUtility
โข Message sending
โข Event receiving
โข Invoke/handle]
THROT_U[.throttled
โข Coalescing]
DIRU --- THROT_U
end
DIM -.IPC Setup.-> DIRA
DIM -.IPC Setup.-> DIRB
DIM -.IPC Setup.-> DIRU
DIRA <==MessageChannel
Direct Connection==> DIRB
DIRA <==MessageChannel
Direct Connection==> DIRU
DIRB <==MessageChannel
Direct Connection==> DIRU
sequenceDiagram
participant Controller as Controller Renderer
participant Main as Main Process
(DirectIpcMain)
participant Output as Output Renderer
Note over Controller,Output: 1. Connection Setup (first message only)
Controller->>Main: IPC: GET_PORT for 'output'
Main->>Main: Create MessageChannel
Main-->>Controller: IPC: Transfer port1
Main-->>Output: IPC: Transfer port2
Note over Controller,Output: Ports are cached for future use
rect rgb(240, 248, 255)
Note over Controller,Output: 2. Direct Messaging (Main process NOT involved)
Controller->>Output: MessageChannel: port.postMessage(data)
Output->>Output: Listener called immediately
end
rect rgb(255, 250, 240)
Note over Controller,Output: 3. Throttled Messaging (Main process NOT involved)
loop High-frequency updates
Controller->>Controller: Queue message locally
end
Controller->>Output: MessageChannel: Flush on microtask (latest only)
Output->>Output: Coalesce & call listeners once
end
rect rgb(240, 255, 240)
Note over Controller,Output: 4. Invoke/Handle Pattern (Main process NOT involved)
Controller->>Output: MessageChannel: invoke('get-data', args)
Output->>Output: Call handler
Output-->>Controller: MessageChannel: Return result
end
Note over Controller,Output: 5. Cleanup (Main detects and notifies)
Output->>Output: Window closes
Main->>Main: Detect close
Main-->>Controller: IPC: Broadcast map update
Controller->>Controller: Clean up cached port
Key Points:
Why MessageChannel?
Why microtask-based throttling?
Why singleton pattern?
Why utility process support?
// Before (Electron IPC via main)
ipcRenderer.send('message-to-output', data)
ipcMain.on('message-to-output', (event, data) => {
outputWindow.webContents.send('message', data)
})
// After (DirectIPC)
await directIpc.send({ identifier: 'output' }, 'message', data)
directIpc.on('message', (sender, data) => {
// handle message
})
If you have a custom IPC system:
directIpc.send*directIpc.ondirectIpc.invoke* and directIpc.handleDirectIpcThrottled now accessed via directIpc.throttled (auto-created).instance() or ._createInstance())Contributions welcome! Please read our Contributing Guide first.
This project uses semantic-release for automated versioning and releases. All commits must follow the Conventional Commits specification. See our Semantic Release Guide for details.
MIT ยฉ Jeff Robbins