How to make postMessage type-safe in under 350 bytes

Cover for How to make postMessage type-safe in under 350 bytes
Blog post by Pavel Grinchenko and Travis Turner

typed-channel makes postMessage type-safe in about 350 bytes. You define message names and payloads once, and TypeScript checks both sides. The same API works across Web Workers, iframes, tabs, and in-page event buses. Version 0.11 adds cancelable requests: cancel a request, and its handler stops too.

Every frontend project reaches a point where two pieces of code need to exchange data, but don’t share a memory space. For example: a page and a Web Worker, a page and an iframe, a Figma plugin’s UI and its sandbox, or two open browser tabs. The browser gives us postMessage for all of these cases.

postMessage is a good primitive, but you have to write a lot of code around it. And it’s the same code in every project. You need a switch on event.data.type to route messages, the message names are plain strings that TypeScript doesn’t check, and listeners are easy to forget to remove. Sooner or later, you’ll also write a “send a message and wait for the reply” helper with its own ids, its own timeout, and its own bugs.

Evil Martians faced this scenario while building Harmonizer, an accessible color palette generator. Harmonizer runs color calculations in a worker, has an internal event bus, and ships as a Figma plugin. Each of these needed its own messaging code, and we didn’t want to write and maintain the same logic three times. So, we built typed-channel, a small library that covers all three cases with a single typed API for sending and receiving messages.

Earlier versions of typed-channel only handled one-way messages. If one side needed an answer back, you still had to build request/response yourself. The latest release, typed-channel 0.11, added the missing part: requests. A request is a message the sender waits on, it has a typed answer, and an abort signal that also stops the work on the other side.

One-way messages cost around 300 bytes, and request/response is a separate endpoint, so you only pay for it if you use it.

The problems with plain postMessage

For context, let’s look at the worker case, as it’s the most common. The page sends a job, the worker sends results back. With plain postMessage it looks like this:

// page.ts
const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });

worker.addEventListener("message", (event) => {
  switch (event.data.type) {
    case "generated:color":
      renderColor(event.data.payload);
      break;
    case "done":
      hideSpinner();
      break;
  }
});

worker.postMessage({ type: "generate:colors", payload: { levels, hues } });
// worker.ts
self.addEventListener("message", (event) => {
  if (event.data.type === "generate:colors") {
    for (const color of calculateColors(event.data.payload)) {
      self.postMessage({ type: "generated:color", payload: color });
    }
    self.postMessage({ type: "done" });
  }
});

This works, and for a small script it’s fine. The problems crop up when the project grows.

  1. Nothing is typed across the boundary: event.data is any. Message names are strings that TypeScript never checks, so a typo like "generated:colors" instead of "generated:color" compiles and fails at runtime. The payload shape is either undocumented, described in a comment, or defined in a type you import on both sides and cast to by hand.
  2. No single place declares which messages each side sends and receives. The worker accepts one set of messages and sends another, and that contract exists only as the union of every case in every switch. When another developer adds a message, they need to find both sides and keep them in sync.
  3. You have to handle cleanup yourself, because addEventListener doesn’t return an unsubscribe function. To stop listening, you keep a reference to the handler and pass the same one to removeEventListener. In components that mount and unmount, this often causes duplicated handlers and leaked listeners.
  4. Every boundary needs its own copy of this code. An iframe needs nearly the same code, plus event.origin checks and a target origin for postMessage. A BroadcastChannel needs the same code again. An in-page event bus uses new CustomEvent("x", { detail }) to send and event.detail to read. The idea is the same across all four APIs, but each one needs its own boilerplate.

Further, the example only sends messages one way, so it hides a fifth problem. The moment the page needs an answer back from the worker, for example “compute this and give me the result,” you have to build request/response yourself.

Rewriting the worker example with typed-channel and messages

typed-channel splits the job in two: a transport moves a message across a boundary, a channel knows which messages exist and what they carry. You write the message types once and get emit and on that TypeScript checks on both sides.

// messages.ts, shared by both sides
export type ClientMessages = {
  "generate:colors": { levels: Level[]; hues: Hue[] };
};

export type WorkerMessages = {
  "generated:color": GeneratedColor;
  done: never; // no payload
};
// page.ts
import { createTypedChannel } from "typed-channel";
import { createPostMessageTransport } from "typed-channel/transports/postMessage";
import type { ClientMessages, WorkerMessages } from "./messages";

const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });

// what we receive, what we send
const channel = createTypedChannel(
  createPostMessageTransport<WorkerMessages, ClientMessages>(worker),
);

channel.on("generated:color", renderColor); // (color: GeneratedColor) => void
channel.on("done", hideSpinner);

channel.emit("generate:colors", { levels, hues });
// worker.ts: same maps, mirrored
const channel = createTypedChannel(
  createPostMessageTransport<ClientMessages, WorkerMessages>(self),
);

channel.on("generate:colors", (payload) => {
  for (const color of calculateColors(payload)) {
    channel.emit("generated:color", color);
  }
  channel.emit("done");
});

This change removes all four problems in one fell swoop:

  1. TypeScript checks message names and payloads where you call emit and on, so a typo like emit("generated:colors", ...) won’t compile.
  2. The messages for each direction are defined in two type maps instead of being spread across switch cases, and the worker uses the same maps in reverse order.
  3. on returns a function that removes the listener, so you don’t need to keep a reference to the handler for removeEventListener.
  4. None of this code is specific to workers. If you change the transport, the same channel works with an iframe, a BroadcastChannel, or an in-page event bus.

The core is 271 bytes minified and brotli-compressed, 341 with the postMessage transport, zero dependencies.

typed-channel in practice: how Harmonizer uses it

Harmonizer uses typed-channel in three places: a worker, an in-page event bus, and a Figma plugin. The emit and on calls are the same in all three, and only the transport changes.

Workers and iframes with postMessage

As above, it’s createPostMessageTransport(worker). The same transport works for a BroadcastChannel or a MessagePort, because they all speak postMessage and message events. An iframe needs one more step. The page sends messages to iframe.contentWindow, but it receives them on its own window. The simplest way is to send the iframe one end of a MessageChannel and give each side’s port to the transport.

An in-page event bus with EventTarget

Harmonizer needs to tell a component “a new level was added, focus its input.” That’s a message with a typed payload, even though it never leaves the page:

import { createEventTargetTransport } from "typed-channel/transports/eventTarget";

type AppEvents = {
  levelAdded: LevelId;
  hueAdded: HueId;
};

export const appEvents = createTypedChannel(createEventTargetTransport<AppEvents>());

// anywhere in the app
appEvents.emit("levelAdded", levelId);
const stop = appEvents.on("levelAdded", (id) => focusLevel(id));

We end up with one map, since both directions are the same. Under the hood it’s an EventTarget and a CustomEvent; you never touch either.

A Figma plugin

This one was a Harmonizer-specific need, and it’s a good test of the design. The plugin UI runs in an iframe while its code runs in a sandbox with its own messaging API: figma.ui.postMessage on one side and parent.postMessage({ pluginMessage }) on the other. typed-channel has no built-in transport for that, because you can write one yourself. A transport is an object with two functions, on and emit, and writing one takes just a dozen lines:

import type { AnyMessages, TypedChannelTransport } from "typed-channel";

function createFigmaPluginTransport<
  Inbound extends AnyMessages,
  Outbound extends AnyMessages,
>(): TypedChannelTransport<Inbound, Outbound> {
  return {
    on(handler) {
      figma.ui.onmessage = handler;
      return () => (figma.ui.onmessage = undefined);
    },
    emit(message) {
      figma.ui.postMessage(message);
    },
  };
}

const uiChannel = createTypedChannel(createFigmaPluginTransport<UIMessages, PluginMessages>());

uiChannel.on("palette:generate", drawPalette);
uiChannel.emit("ready", { sandboxVersion, storedConfig });

The UI side is the mirror image, wrapping parent.postMessage and window.onmessage. After that, the plugin code reads the same as the worker code and the event bus code: typed names, typed payloads, one way to subscribe and unsubscribe.

A channel can also take several transports at once. Pass an EventTarget transport and a BroadcastChannel transport together, and one emit reaches the current tab and every other open tab:

const channel = createTypedChannel([localTransport, broadcastTransport]);

When a message isn’t enough: requests

Everything so far is fire-and-forget. While that actually covers a lot, it doesn’t handle cases where the sender needs an answer: “compute this and give me the result”, “do you have this in cache?”, “are you ready?” With plain messages you build request/response yourself, and it’s always the same code:

let nextId = 0;
const pending = new Map<number, { resolve: Function; reject: Function }>();

function callWorker(payload) {
  const id = nextId++;
  return new Promise((resolve, reject) => {
    pending.set(id, { resolve, reject });
    worker.postMessage({ type: "compute", id, payload });
    setTimeout(() => {
      pending.delete(id);
      reject(new Error("timeout"));
    }, 5000);
  });
}

worker.addEventListener("message", (event) => {
  if (event.data.type === "compute:result") {
    pending.get(event.data.id)?.resolve(event.data.result);
    pending.delete(event.data.id);
  }
});

This helper still has several problems:

  • When the timeout fires, the promise rejects, but the worker keeps computing.
  • There’s no way to cancel a request, so if the user navigates away, the result still arrives for a component that no longer exists.
  • Errors thrown in the worker never reach the caller.
  • If the worker crashes, pending requests wait out their full timeout and then fail with a generic timeout error.
  • Message routing is still an untyped check on event.data.type.

typed-channel 0.11 adds a second factory, createTypedRpcChannel, that does all of this. It keeps emit/on for messages and adds request/handle for calls:

// messages.ts
export type WorkerRequests = {
  "compute:colors": (payload: ComputePayload) => GeneratedColor[];
};
export type NoRequests = Record<never, never>;
// page.ts
import { createTypedRpcChannel, requests } from "typed-channel";
import { createPostMessageTransport } from "typed-channel/transports/postMessage";

// messages: what we receive, what we send. requests: what we handle, what we call.
const channel = createTypedRpcChannel(
  createPostMessageTransport<WorkerMessages, ClientMessages>(worker),
  requests<NoRequests, WorkerRequests>(),
);

const colors = await channel.request("compute:colors", { levels, hues }, {
  signal: AbortSignal.timeout(5000),
});
// worker.ts: maps mirrored again
const channel = createTypedRpcChannel(
  createPostMessageTransport<ClientMessages, WorkerMessages>(self),
  requests<WorkerRequests, NoRequests>(),
);

channel.handle("compute:colors", (payload, { signal }) => calculateColors(payload, { signal }));

Each request is typed as a function. The parameter is the payload the caller sends, and the return type is the answer it gets back. In the type map, you write the return type as a plain value (GeneratedColor[]), rather than a promise. The handler can return that value directly or return a promise of it, and request resolves to the plain value either way. If the handler throws, the caller’s request promise rejects.

requests() has no runtime cost. It only passes the two maps along so TypeScript can infer the types. The library also doesn’t validate the payload or the result. Both are structured and cloned exactly as you pass them.

The part we’re most happy with is cancellation. There’s no timeout option, because the platform already has one: request accepts an AbortSignal, and AbortSignal.timeout(5000) allows setting a timeout value. Aborting the signal rejects the promise and sends an abort message to the other side, which aborts the signal the handler received.

So, “the user navigated away, or it took too long, whichever comes first” looks like this:

const controller = new AbortController();
unmount.then(() => controller.abort());

await channel.request("compute:colors", payload, {
  signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]),
});

…and the worker stops the work instead of finishing it for nobody.

One rule follows from this: a request with no handler on the other side never resolves by itself. It ends only through its signal. If the peer happens not to be ready, pass a timeout signal.

There are two smaller behaviors that are worth note.

  1. On a BroadcastChannel, where a request may reach several tabs, the first response wins and the others are ignored, so “does any tab have this cached?” is a single request.
  2. Every channel also has an unlisten() method that disconnects it from its transports, for example when a component unmounts. On an RPC channel, it doesn’t leave requests hanging: pending requests on this side are rejected, the other side gets an abort for each of them, and the signal of every handler still running is aborted.

Requests live in a separate factory on purpose. Messages stay at 271 bytes; createTypedRpcChannel is 1 kB and includes everything. Simply import the one you need.

Try typed-channel

Just run:

npm install typed-channel

Then, start with createTypedChannel and a message map. Add createTypedRpcChannel when you need an answer back. Write a transport when you hit a boundary the built-in ones don’t cover; it’s two functions to do so.

The documentation has guides for messages, requests, and transports, and the repo has runnable examples for workers, iframes, and BroadcastChannel. We added requests because a real project asked for them, so if you’re building something with it, or something is missing, open an issue!