Workers
Why a Pear app's peer-to-peer logic lives in a Bare worker behind a single IPC stream — what the pattern buys you, where to put the boundary, and how the host and worker halves talk.
A Pear application splits into two halves: a UI (the renderer, terminal, or mobile shell) and a worker that owns everything peer-to-peer. The worker is a Bare process the host spawns at startup, and it's where Hyperswarm, Corestore, Hypercore, and any native addons run. The two halves only ever see each other through a single Inter-Process Communication (IPC) duplex stream.
This page is about the pattern, not the API. For the call signatures and IPC stream type, see Running workers in the runtime reference.
Workers as a local backend
The mental model is the worker is your application's local backend. The renderer is a client to that backend in the same way a web app is a client to a remote API — it makes requests, it gets streaming events back, it doesn't own state. The difference is the "server" is a sibling process on the same machine, started and stopped by the host.
In a desktop Pear app the host is the Electron main process; in a mobile Pear app it's the Bare iOS / Bare Android shell; in a terminal app it's the pear CLI itself. The worker code is identical across all of them — only the UI half and the bridge that forwards IPC change.
Why the split
Three concrete reasons to put peer-to-peer code in a worker rather than inline in the UI:
- Native addons stay out of the renderer. Hypercore, Hyperswarm, sodium, and many other Pear building blocks load native modules. Electron's renderer cannot load native code under
sandbox: true, and asandbox: falserenderer is a large attack surface. Keeping native code in the worker keeps the renderer untrusted. - The renderer becomes portable. Because the worker never imports DOM APIs and the UI never imports Corestore or Hyperswarm, you can swap the UI for a different framework (or a different platform's UI entirely) without rewriting the peer-to-peer logic. This is what makes Keet's identical experience across desktop, mobile, and terminal possible.
- One IPC channel, one place to audit. Every byte that crosses from peers into your application crosses the IPC boundary first. Validation, framing decisions, rate limits, and observability all sit at that one chokepoint instead of being scattered across the UI.
See Runtime and languages for the wider "Pear-end / UI" framing and why it's the recommended app shape.
The IPC contract
The host spawns a worker by calling pear.run (or the static helper PearRuntime.run for one-off cases) with the worker's entrypoint and a list of arguments:
const IPC = pear.run('./workers/main.js', [pear.storage])
IPC.on('data', (data) => {
console.log('data from worker', data)
})
IPC.write('hello')IPC is a duplex stream: bytes the host writes show up on the worker side, and bytes the worker writes show up here. Inside the worker the other side of that same stream is Bare.IPC:
import Corestore from 'corestore'
const storage = Bare.argv[2]
Bare.IPC.on('data', (data) => console.log(data.toString()))
Bare.IPC.write('Hello from worker')
const corestore = new Corestore(storage)
// ... open Hypercores, replicate over Hyperswarm, etc.A few things about that snippet aren't obvious if you're new to Bare:
Bare.argvindexing matches Node'sprocess.argv.Bare.argv[0]is the Bare binary,Bare.argv[1]is the worker script path, andBare.argv[2]is the first argument you passed. Anything beyond that lands atBare.argv[3],[4], and so on.- The IPC stream carries bytes, not objects. There's no built-in JSON or length-framing. Either send one message per
write()(Bare's IPC preserves write boundaries on the receiving side when the message is small enough not to be split), or pick a framing format like newline-delimited JSON or compact-encoding and stick to it on both ends. pear.storageis the recommended first argument. It points at a per-app directory the host has already prepared (see Storage and distribution). Worker code stays portable as long as it treatsBare.argv[2]as "wherever the host says my storage is" rather than hard-coding a path.
Structured RPC over IPC
Raw Inter-Process Communication (IPC) carries bytes, not typed messages. For a handful of one-off signals, length-preserving writes or newline-delimited JSON may be enough (see Compact encoding for binary framing). As the host–worker protocol grows, manually pairing requests and responses becomes error-prone.
bare-rpc adds a thin Remote Procedure Call (RPC) layer on top of a duplex stream. Each message has a numeric command id and a payload; handlers dispatch on req.command:
import RPC from 'bare-rpc'
export const RPC_MESSAGE = 1
const rpc = new RPC(Bare.IPC, (req) => {
if (req.command === RPC_MESSAGE) {
console.log(req.data.toString())
}
})
const req = rpc.request(RPC_MESSAGE)
req.send(Buffer.from('Hello from worker'))Share command constants between host and worker (a shared commands.mjs module works well). bare-rpc scales to moderate protocols but still leaves schema, versioning, and method naming to you.
hrpc generates typed client and server stubs from a schema. Define request and response types (often via hyperschema), register methods, run the code generator, and import the result:
import HRPC from './spec/hrpc/index.js'
const rpc = new HRPC(Bare.IPC)
rpc.onHello(({ world }) => ({
message: `Hello ${world}, from worker`
}))
await rpc.hello({ world: 'host' })The host side constructs new HRPC(IPC) with the same generated module. Method names and encodings stay in sync because both sides compile from one definition.
| Approach | Best for |
|---|---|
| Raw IPC | Prototypes, single-channel streaming |
| bare-rpc | Small fixed command sets, mobile worklets |
| HRPC | Many methods, evolving schemas, shared types |
Stay on raw IPC until message pairing hurts; adopt HRPC when the protocol is large enough that hand-maintained command ids become a maintenance burden.
Where the boundary should sit
A pragmatic rule of thumb: anything that touches peers, storage, or cryptography belongs on the worker side; anything that touches a screen, a keyboard, or a window belongs on the UI side. When in doubt, ask "could this code run unchanged if I swapped Electron for a terminal UI?" — if yes, it's worker code; if not, it's UI code.
The renderer in a typical Pear Electron app, then, owns very little: layout, event listeners, and a thin transport that posts user actions to the worker and renders whatever streams back. The worker is where the application actually lives.
Lifecycle and multiple workers
A host can run more than one worker. Common patterns:
- One main worker that owns the application's primary append-only logs and swarm membership. This is the usual shape and what Add persistence with Corestore (part 2 of the getting started path) builds.
- Additional ephemeral workers for one-off jobs — a heavy import, a video transcode, a synchronous CPU task — that exit when their work is done. These keep the main worker's event loop free.
Each worker is its own Bare process, has its own IPC stream on the host side, and can be torn down independently. The host is responsible for cleaning up on before-quit; see how getWorker() registers an app.on('before-quit', …) listener in the production-shape tutorial for one way to wire that up.
See also
- Running workers in the runtime reference —
pear.run, theIPCduplex stream, andBare.IPCon the worker side. - Runtime and languages — the broader "Pear-end / UI" pattern, Bare's role, and how non-JavaScript code joins in via native addons.
- Pear desktop application architecture — how workers, the preload bridge, and the OTA updater fit together in a real Electron app.
- Storage and distribution — where
pear.storageactually points on each operating system. - Add persistence with Corestore — a tutorial that wires a real
pear-chatworker behind awindow.bridgeAPI. - Corestore reference — the storage factory typically opened on
Bare.argv[2]inside a worker. - Compact encoding reference — binary framing for IPC messages crossing the host/worker boundary.
- The upstream source:
hello-pear-electron's Workers.