I am typing this and the cursor blinks. It blinks because the main thread is free. But for how long?

We are building apps that are too big for the browser. Not "big" like "lots of HTML". Big like "video editor", "IDE", "database". We are parsing syntax trees. We are processing audio buffers. We are running SQL queries against a WASM Postgres instance. And every time we do one of those things, the cursor stops blinking.

You paste 10,000 lines of JSON into a text area. The frame drops. The scroll stutters. The user sighs. We know the solution. It’s been there since 2009. Web Workers. The browser is multi-threaded! We can spawn threads! We can run code in parallel! We can have our cake and eat it too!

So why aren't we doing it?

The postMessage Problem

Because postMessage is awful. It is the assembly language of the web. It's like sending smoke signals in binary. You send a message. Maybe it’s a string. Maybe it’s an object. You lose your prototypes. You lose your functions. You lose your types. And then you wait.

You add an event listener. onmessage. You get an event. e.data. What is data? Who knows. It’s any. You have to invent a protocol. "Okay, I'll send { type: 'REQUEST', id: 1, payload: ... }". And the worker will send back { type: 'RESPONSE', id: 1, payload: ... }.

Now you have to match the IDs. You have to manage the state. You have to handle errors. What if the worker throws? The error doesn't cross the boundary. It just dies silently in the console.

It is friction. It is mental overhead. It is enough to make you say, "I'll just put it in a useEffect and hope nobody notices the jank."

The Dream of the Invisible Worker

I don't want to think about workers. I don't want to think about serialization. I want to call a function.

// I want this
const result = await api.heavyComputation(data);

I want to interact with a stateful system. I want to increment counters, update documents, and subscribe to real-time streams of events.

// I want to call methods on nested modules
await api.Counter.inc(1);

// I want to subscribe to streams
api.seconds().subscribe(tick => {
  console.log(tick);
});

Enter werkbank/rpc

So I built it. It is a bridge. A transparent, type-safe bridge between the main thread and the worker. It starts with a Proxy. When you create a client, we give you a ghost. It looks like your API. It has the same methods. It has the same types. But it is hollow. When you call a method on this ghost, it intercepts the call. It packages the arguments. It generates a UUID. It sends a REQUEST message to the worker.

And then it waits. But it doesn't just wait for a promise. It waits for an Observable.

Why RxJS?

In most RPC libraries, everything is a Promise. async/await. You ask a question, you get an answer. But sometimes, a function isn't just a question. Sometimes it's a conversation.

Imagine a file upload. You don't just want to know when it's done. You want to know the progress. "10%... 20%... 50%...". A Promise can't do that. A Promise resolves once. But an Observable can emit many values over time.

With werkbank/rpc, if your worker function returns an Observable, the client receives an Observable. You can subscribe to progress updates, real-time data feeds, or log streams.

And the best part? Cancellation is free. One of the biggest pain points with Promises is that you can't cancel them. If the user navigates away while a heavy calculation is running, that calculation keeps running. It eats CPU. It drains battery.

But you can unsubscribe from an Observable.

// React Component Example
useEffect(() => {
  const sub = api.search(query).subscribe(results => {
    setResults(results);
  });

  // Cleanup function: runs when component unmounts or query changes
  return () => sub.unsubscribe(); 
}, [query]);

When you unsubscribe on the main thread, we send an UNSUBSCRIBE signal to the worker. The worker tears down the operation instantly. No more zombie computations.

The Best of Both Worlds

"But I don't want to use RxJS for everything!" I hear you. Sometimes you just want await. So I built a hybrid.

function observablePromise(observable, promise) {
    let hybrid = observable as any;
    hybrid.then = promise.then.bind(promise); // It acts like a Promise
    return hybrid;
}

You can await it. You can .subscribe() to it. It adapts to you.

Note: This is pragmatic magic. "Thenables" are a known pattern in JS, and mixing them with Observables gives us the best of both worlds. You get the simplicity of await for single values, and the power of RxJS for streams, without changing your API surface.

The Cost of Serialization

Speaking of performance, let's talk about the elephant in the room. "But wait," I hear you say. "What about serialization? Isn't postMessage slow?" Yes, it can be. If you send a 10MB JSON object, the browser has to clone it. That takes time.

But werkbank/rpc doesn't solve this by magic. It solves it by letting you use Transferables. If you pass an ArrayBuffer, we transfer it. Zero copy. Instant.

And for everything else? The cost of cloning a few kilobytes is measured in microseconds. The cost of blocking the main thread for 50ms is measured in user frustration.

I will take the serialization cost over the jank any day.

Shared Workers? Yes.

We didn't stop at dedicated workers. We also support SharedWorkers. Why? Because if you have 10 tabs open, you don't want 10 database connections. You want one. werkbank/rpc detects if you are connecting to a SharedWorker and handles the port negotiation automatically. The API remains exactly the same. You just change new Worker() to new SharedWorker().

This is huge for things like local-first apps. You can run a full SQL database (like PGlite) in a SharedWorker. If you ran that on the main thread, every query would freeze the UI. In a worker, it's seamless. Your UI just subscribes to the queries.

The Worker Side

Okay, the client is magic. But what about the worker? Is it a mess of boilerplate? No. It is just code.

// worker.ts
import { interval } from "rxjs";

let count = 0;

const api = {
  // Nested modules? No problem.
  Counter: {
    inc(val: number) { count += val; },
    get() { return count; }
  },

  // Streaming function
  seconds() {
    return interval(1000).pipe(map(i => `Tick ${i}`));
  }
};

createWorker({ mod: api });

That's it. No message handlers. No switch statements. Just functions. The createWorker function wraps your API. It listens for those REQUEST messages. It finds the right function. It runs it.

If it throws, we catch it. We serialize the error. We send it back. The client promise rejects. The try/catch block on the main thread works exactly as you expect.

Type Safety (The Secret Sauce)

But the real magic is the types. We use TypeScript's mapped types to project the worker's types onto the client. It acts like a mirror.

// packages/werkbank/src/rpc/client/index.ts

type MyceliumClient<Config> = {
    [Key in keyof Config]: Config[Key] extends (...args: any[]) => any
    ? Fn<Parameters<Config[Key]>, ReturnType<Config[Key]>> // It matches!
    : MyceliumClient<Config[Key]>; // It recurses!
};

This isn't just flat mapping. It's recursive. If your API has nested objects (e.g., api.users.create(...)), the type mapper follows them. It builds a deep proxy structure that mirrors your exact API shape, all the way down.

The experience is magical. You type api., and your IDE shows you Counter. You type Counter., and it shows inc. You hover over inc, and it tells you it expects a number. If I change the add function in the worker to take a string, the client code turns red. Instantly. Across the thread boundary.

Conclusion

The main thread is for the UI. It is for the user. It is sacred. Everything else—parsing, calculating, searching, filtering—belongs in a worker.

With werkbank/rpc, the barrier is gone. The friction is gone. You can treat the worker as just another module. Just another import.

Stop blocking the main thread. It's rude. Build the app you want to build, not the one the main thread lets you build.

Related Post

Getting Started

TODO: Link to Getting Started Guide

Disclaimer

This is a Proof of Concept (PoC). It's running in my personal workspace. It's not on GitHub yet. It's not battle-tested in a million-user app. It's an experiment in "What if?". I plan to open source this properly at a later date, once I've ironed out the kinks and written some actual documentation. But for now, I just wanted to share the idea. Because sometimes, the best way to move forward is to look at what we're doing and ask: "Does it have to be this hard?"