npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

electron-direct-ipc

v2.4.0

Published

Type-safe, high-performance inter-process communication for Electron applications.

Downloads

166

Readme

npm version npm downloads CI License: MIT Package Format API Docs npm provenance

Electron Direct IPC

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.

Features

  • 🚀 Direct Communication - Renderers communicate via MessageChannel, bypassing main process
  • 🔒 Type-Safe - Full TypeScript generics for compile-time safety
  • High Performance - Microtask-based throttling for high-frequency updates
  • 🎯 Flexible Targeting - Send by identifier, webContentsId, or URL pattern
  • 🔄 Bidirectional - Request/response with async invoke/handle pattern
  • 📡 Event-Driven - Built on EventEmitter with automatic lifecycle management
  • 📦 Dual Format - Works with both ESM (import) and CommonJS (require)
  • 🔧 Utility Process Support - Full communication with Electron UtilityProcess workers
  • 🔀 SharedArrayBuffer Support - True shared memory and zero-copy ArrayBuffer transfers
  • 🧪 Well Tested - Comprehensive unit, integration, and E2E tests with full coverage

Table of Contents

Installation

npm install electron-direct-ipc

Quick Start

1. Set up the main process

// main.ts
import { DirectIpcMain } from 'electron-direct-ipc/main'

DirectIpcMain.init()

// That's it! DirectIpcMain handles all the coordination automatically

2. Define your message types (optional but recommended for TypeScript)

// 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'

3. Use in renderer processes

// 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

Core Concepts

MessageChannel-Based Communication

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)

Targeting Modes

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.

Throttled vs Non-Throttled

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)
}

Type Safety

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 // ✅
})

API Reference

DirectIpcRenderer

Main class for renderer process communication.

Constructor & Singleton

// 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 }
)

Sending Messages

// 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')

Receiving Messages

// 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

Invoke/Handle Pattern

// 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 }
)

Utility Methods

// 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

Events (localEvents)

// 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) => {})

DirectIpcThrottled

Accessed via directIpc.throttled property. Provides lossy message coalescing for high-frequency updates.

When to Use Throttled

Use throttled when:

  • Sending high-frequency state updates (position, volume, progress)
  • Only the latest value matters (replaceable state)
  • Experiencing backpressure (sender faster than receiver)
  • UI updates that can safely skip intermediate frames

Don't use throttled when:

  • Every message is unique and important
  • Messages represent discrete events
  • Order matters for correctness
  • Need guaranteed delivery

Throttled API

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) => {})

How Throttling Works

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
})

DirectIpcMain

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.

DirectIpcUtility

For communication with Electron UtilityProcess workers. Use utility processes for CPU-intensive tasks that would block the renderer.

Setup

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}`)
})

DirectIpcUtility API

// 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) => {})

Registration States

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.

SharedArrayBuffer & Transfers

DirectIPC supports both SharedArrayBuffer (true shared memory) and ArrayBuffer transfers (zero-copy ownership transfer) for high-performance binary data communication.

When to Use

| Feature | Use Case | Behavior | |---------|----------|----------| | SharedArrayBuffer | Real-time audio/video, shared state | Both processes can read/write the same memory | | ArrayBuffer Transfer | Large one-time data, image processing | Zero-copy, but sender loses access | | Normal send | Small data, JSON-serializable | Deep copy (structured clone) |

SharedArrayBuffer (Shared Memory)

SharedArrayBuffer allows multiple processes to access the same memory region. Perfect for real-time audio processing, video frames, or shared state.

import {
  createSharedTypedArray,
  createSharedBufferFrom,
  SharedAtomics
} from 'electron-direct-ipc'

// Create a shared buffer with typed array view
const audioBuffer = createSharedTypedArray(Float32Array, 4096)

// Send to another process - both can now read/write!
await directIpc.send({ identifier: 'audio-processor' }, 'audio-buffer', audioBuffer.buffer)

// Receiver creates a view of the same memory
directIpc.on('audio-buffer', (sender, buffer: SharedArrayBuffer) => {
  const samples = new Float32Array(buffer)
  // Modifications here are visible to the sender!
  samples[0] = 1.0
})

Atomic Operations for Thread Safety:

import { createSharedTypedArray, SharedAtomics } from 'electron-direct-ipc'

// Create shared counter
const counter = createSharedTypedArray(Int32Array, 1)

// Atomically increment (safe for concurrent access)
const oldValue = SharedAtomics.add(counter.view, 0, 1)

// Compare-and-swap pattern
const current = SharedAtomics.compareExchange(counter.view, 0, expectedValue, newValue)

// Wait for value to change (in utility process)
const result = SharedAtomics.wait(counter.view, 0, expectedValue, 1000) // timeout in ms

Important: SharedArrayBuffer requires specific security headers:

// In your main process when creating windows:
const win = new BrowserWindow({
  webPreferences: {
    contextIsolation: true,
    // Enable SharedArrayBuffer
  }
})

// Set required headers
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
  callback({
    responseHeaders: {
      ...details.responseHeaders,
      'Cross-Origin-Opener-Policy': ['same-origin'],
      'Cross-Origin-Embedder-Policy': ['require-corp']
    }
  })
})

ArrayBuffer Transfer (Zero-Copy)

For large buffers where you don't need shared access, transfer ownership for zero-copy performance:

// Create a large buffer
const imageData = new ArrayBuffer(1920 * 1080 * 4) // 8MB RGBA image

// Transfer ownership - zero copy, but buffer becomes unusable in sender
await directIpc.send(
  { identifier: 'image-processor' },
  'process-image',
  imageData,
  { transfer: [imageData] }
)

// imageData is now detached and unusable here!
console.log(imageData.byteLength) // 0

// Receiver gets full ownership
directIpc.on('process-image', (sender, buffer: ArrayBuffer) => {
  console.log(buffer.byteLength) // 8294400
  // Process the image...
})

SharedBufferUtils API

import {
  // Check availability
  isSharedArrayBufferAvailable,
  isSharedArrayBuffer,
  isTransferable,

  // Create shared buffers
  createSharedBuffer,           // Create raw SharedArrayBuffer
  createSharedTypedArray,       // Create with typed array view
  createSharedBufferFrom,       // Copy existing typed array to shared
  copyToSharedBuffer,           // Copy ArrayBuffer to SharedArrayBuffer

  // Create views
  createViewOfSharedBuffer,     // Create typed view of existing buffer

  // Helpers
  extractTransferables,         // Find all transferables in a message

  // Atomic operations
  SharedAtomics                 // Thread-safe operations
} from 'electron-direct-ipc'

Pattern: Real-Time Audio Processing

// Main audio renderer
const audioBuffer = createSharedTypedArray(Float32Array, 4096)

// Share with audio processor utility
await directIpc.send({ identifier: 'audio-processor' }, 'set-buffer', audioBuffer.buffer)

// Audio processor utility
directIpc.on('set-buffer', (sender, buffer: SharedArrayBuffer) => {
  const samples = new Float32Array(buffer)

  // Process audio in real-time
  setInterval(() => {
    for (let i = 0; i < samples.length; i++) {
      samples[i] = samples[i] * 0.5 // Apply gain
    }
  }, 10)
})

Pattern: Progress Counter with Atomics

// Create shared progress counter
const progress = createSharedTypedArray(Int32Array, 1)

// Send to worker
await directIpc.send({ identifier: 'worker' }, 'start-task', progress.buffer)

// Worker updates progress atomically
directIpc.on('start-task', async (sender, buffer: SharedArrayBuffer) => {
  const counter = new Int32Array(buffer)
  for (let i = 0; i <= 100; i++) {
    SharedAtomics.store(counter, 0, i)
    await doWork()
  }
})

// Main process reads progress
setInterval(() => {
  const currentProgress = SharedAtomics.load(progress.view, 0)
  updateProgressBar(currentProgress)
}, 100)

Usage Patterns

Pattern 1: Simple Messaging

// Sender
await directIpc.send({ identifier: 'output' }, 'play-button-clicked')

// Receiver
directIpc.on('play-button-clicked', (sender) => {
  playbackEngine.play()
})

Pattern 2: State Updates

// 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)
})

Pattern 3: Request-Response

// 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)
}

Pattern 4: Broadcast

// 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)

Pattern 5: Mixed Throttled/Non-Throttled

// 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)
})

Pattern 6: Error Handling

// 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)
}

Pattern 7: Dynamic Window Discovery

// 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}`)
})

Pattern 8: Utility Process Communication

// 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)
})

Performance Guide

Choosing Throttled vs Non-Throttled

Use regular directIpc for:

  • User actions (clicks, keypresses)
  • State changes (song changed, clip added)
  • Commands (play, pause, stop)
  • Discrete events that must all be delivered

Use directIpc.throttled for:

  • Mouse/cursor position (60+ Hz)
  • Playback position (30-60 Hz)
  • Volume levels (real-time)
  • Progress bars (real-time)
  • Any replaceable state where only latest value matters

Latency Characteristics

| 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 |

Memory Usage

DirectIPC is designed to be lightweight:

  • DirectIpcRenderer: ~8KB per instance
  • DirectIpcUtility: ~8KB per instance
  • DirectIpcThrottled: ~2KB per instance (auto-created)
  • MessageChannel: ~1KB per connection
  • Pending messages: O(channels × targets)

Benchmarks

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 |

DirectIPC vs Traditional IPC

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.

Testing

DirectIPC includes comprehensive test utilities.

Unit Testing

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')
  })
})

Integration Testing

See DirectIpc.integration.test.ts for examples of testing full renderer-to-renderer communication with MessageChannel.

E2E Testing with Playwright

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()
})

Architecture

Overview

graph TB
    subgraph Main["Main Process"]
        DIM[DirectIpcMain<br/>• Tracks all renderer windows<br/>• Tracks utility processes<br/>• Creates MessageChannels on demand<br/>• Broadcasts map updates]
    end

    subgraph RendererA["Renderer A (e.g., Controller)"]
        DIRA[DirectIpcRenderer<br/>• Message sending<br/>• Event receiving<br/>• Invoke/handle]
        THROT_A[.throttled<br/>• Coalescing]
        DIRA --- THROT_A
    end

    subgraph RendererB["Renderer B (e.g., Output)"]
        DIRB[DirectIpcRenderer<br/>• Message sending<br/>• Event receiving<br/>• Invoke/handle]
        THROT_B[.throttled<br/>• Coalescing]
        DIRB --- THROT_B
    end

    subgraph UtilityProc["Utility Process (e.g., Worker)"]
        DIRU[DirectIpcUtility<br/>• Message sending<br/>• Event receiving<br/>• Invoke/handle]
        THROT_U[.throttled<br/>• Coalescing]
        DIRU --- THROT_U
    end

    DIM -.IPC Setup.-> DIRA
    DIM -.IPC Setup.-> DIRB
    DIM -.IPC Setup.-> DIRU
    DIRA <==MessageChannel<br/>Direct Connection==> DIRB
    DIRA <==MessageChannel<br/>Direct Connection==> DIRU
    DIRB <==MessageChannel<br/>Direct Connection==> DIRU

Message Flow

sequenceDiagram
    participant Controller as Controller Renderer
    participant Main as Main Process<br/>(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:

  1. Connection Setup - One-time overhead (~10ms) to establish MessageChannel
  2. Message Sending - Direct port communication bypasses main process (near-zero overhead)
  3. Message Receiving - Non-throttled calls listeners immediately; throttled coalesces per microtask
  4. Cleanup - Automatic when windows close

Design Decisions

Why MessageChannel?

  • Zero main process overhead after setup
  • Native browser API (well-tested, performant)
  • Supports structured clone algorithm
  • Automatic cleanup when windows close

Why microtask-based throttling?

  • Predictable ~1ms latency
  • Natural coalescing boundary (one tick)
  • No timers needed (more efficient)
  • Works with React's batching

Why singleton pattern?

  • One DirectIpcRenderer/DirectIpcUtility per process
  • Prevents duplicate port connections
  • Centralized lifecycle management
  • Easier debugging

Why utility process support?

  • Offload CPU-intensive work from renderers
  • Keep UI responsive during heavy computation
  • Same familiar API as DirectIpcRenderer
  • Automatic registration and lifecycle management

Migration Guide

From Electron IPC

// 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
})

From Custom IPC Solutions

If you have a custom IPC system:

  1. Define your message/invoke maps
  2. Replace send calls with directIpc.send*
  3. Replace listeners with directIpc.on
  4. Replace RPC with directIpc.invoke* and directIpc.handle
  5. Remove main process coordination code (DirectIpcMain handles it)

Breaking Changes from v1.x

  • DirectIpcThrottled now accessed via directIpc.throttled (auto-created)
  • Constructor is now private (use singleton .instance() or ._createInstance())
  • Some method signatures changed for better type safety

Contributing

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.

License

MIT © Jeff Robbins

Links