DEV Community

CertosinoLab
CertosinoLab

Posted on Originally published at certosinolab.blogspot.com

Build a Cross-Tab Drawing Canvas with JavaScript and BroadcastChannel

Abstract neon browser tabs connected by glowing light trails, representing real-time synchronization with the BroadcastChannel API

What if drawing in one browser tab could instantly affect another one?

The browser already provides an API that makes this possible without WebSockets, polling or a backend: BroadcastChannel.

I used it to build a small visual experiment where you can draw a neon trail in one browser tab and watch the same trail appear in every other open instance of the page.

The project uses:

  • HTML Canvas
  • Pointer Events
  • BroadcastChannel
  • requestAnimationFrame
  • plain JavaScript

No framework. No WebSocket server. No external library.

Try the demo

Open the Pen in two browser tabs, then start drawing in one of them.

The other tab should reproduce the trail almost instantly.

The communication layer is tiny

Creating a broadcast channel only requires a name:

const channel = new BroadcastChannel(
  "cross-tab-neon-canvas-v1"
);
Enter fullscreen mode Exit fullscreen mode

Every page from the same origin that creates a channel with that name can exchange messages with the others.

A tab can send a message with:

channel.postMessage({
  type: "draw",
  x: 0.42,
  y: 0.61
});
Enter fullscreen mode Exit fullscreen mode

And the other tabs can listen for it:

channel.addEventListener("message", ({ data }) => {
  if (data.type === "draw") {
    // Render the received drawing data
  }
});
Enter fullscreen mode Exit fullscreen mode

That is essentially the entire communication layer.

The more interesting part is deciding what to send.

Sending line segments instead of pixels

There is no reason to send the contents of the canvas itself.

Instead, while the pointer moves, the application keeps track of the previous and current positions.

Together they define a small line segment:

const segment = {
  x1: lastPoint.x,
  y1: lastPoint.y,
  x2: next.x,
  y2: next.y,
  width: 3.4
};
Enter fullscreen mode Exit fullscreen mode

The segment is rendered locally:

addSegment(segment, color);
Enter fullscreen mode Exit fullscreen mode

and then sent to the other tabs:

channel.postMessage({
  type: "draw",
  id: tabId,
  color,
  segment
});
Enter fullscreen mode Exit fullscreen mode

A receiving tab simply renders the same segment on its own canvas:

if (data.type === "draw") {
  addSegment(
    data.segment,
    data.color,
    true
  );
}
Enter fullscreen mode Exit fullscreen mode

There is no shared canvas.

Every tab owns and renders its own canvas. BroadcastChannel only distributes the drawing instructions.

Normalizing the coordinates

There is a small problem with sending pointer coordinates directly.

Two tabs do not necessarily have the same window size.

If Tab A sends:

{
  x: 1200,
  y: 500
}
Enter fullscreen mode Exit fullscreen mode

those coordinates may not make sense in a smaller window.

Instead, I convert the pointer position to values between 0 and 1:

function normalizedPoint(event) {
  const rect = canvas.getBoundingClientRect();

  return {
    x:
      (event.clientX - rect.left) /
      rect.width,

    y:
      (event.clientY - rect.top) /
      rect.height
  };
}
Enter fullscreen mode Exit fullscreen mode

The middle of the canvas is therefore approximately:

{
  x: 0.5,
  y: 0.5
}
Enter fullscreen mode Exit fullscreen mode

Each receiving tab converts those values back to its own dimensions:

const x = segment.x1 * width;
const y = segment.y1 * height;
Enter fullscreen mode Exit fullscreen mode

This keeps the drawing in roughly the same relative position even when the browser windows have different sizes.

Building the neon trail

The strokes are intentionally temporary.

Every segment stores its creation time:

segments.push({
  ...segment,
  color: strokeColor,
  born: performance.now()
});
Enter fullscreen mode Exit fullscreen mode

Inside the animation loop, its age determines its opacity:

const age = now - segment.born;

const life =
  1 - age / FADE_MS;
Enter fullscreen mode Exit fullscreen mode

Once the segment becomes too old, it is removed:

if (age > FADE_MS) {
  return false;
}
Enter fullscreen mode Exit fullscreen mode

The animation runs through:

requestAnimationFrame(render);
Enter fullscreen mode Exit fullscreen mode

To create the neon effect, each stroke is rendered twice.

First, a wider transparent line creates the glow:

ctx.globalAlpha = life * 0.18;
ctx.lineWidth = segment.width * 5;
ctx.shadowColor = segment.color;
ctx.shadowBlur = 28 * life;
Enter fullscreen mode Exit fullscreen mode

Then a smaller, brighter line creates the core:

ctx.globalAlpha =
  Math.pow(life, 0.72) * 0.92;

ctx.lineWidth = segment.width;
ctx.shadowBlur = 12 * life;
Enter fullscreen mode Exit fullscreen mode

I also use:

ctx.globalCompositeOperation = "lighter";
Enter fullscreen mode Exit fullscreen mode

This makes overlapping colors behave more like overlapping light.

A few small particles complete the effect.

Giving every tab its own color

Each tab gets a unique identifier:

const tabId =
  crypto.randomUUID?.() ||
  Math.random().toString(36).slice(2);
Enter fullscreen mode Exit fullscreen mode

That identifier is mapped to a small palette:

const palette = [
  "#7CFFB2",
  "#7DD3FC",
  "#C084FC",
  "#F9A8D4",
  "#FDE68A"
];
Enter fullscreen mode Exit fullscreen mode

When a drawing segment is broadcast, its color is sent with it:

channel.postMessage({
  type: "draw",
  id: tabId,
  color,
  segment
});
Enter fullscreen mode Exit fullscreen mode

If several tabs are drawing, their trails can therefore have different colors.

How do you count the open tabs?

One interesting limitation is that BroadcastChannel does not expose a list of subscribers.

There is no:

channel.getConnectedTabs();
Enter fullscreen mode Exit fullscreen mode

To display a small indicator such as:

3 tabs connected
Enter fullscreen mode Exit fullscreen mode

the application implements a basic presence mechanism.

Each tab periodically sends a heartbeat:

setInterval(() => {
  send("heartbeat");
  prunePeers();
}, HEARTBEAT_MS);
Enter fullscreen mode Exit fullscreen mode

Whenever another tab sends a message, its last activity time is stored:

peers.set(id, {
  lastSeen: Date.now(),
  color: peerColor
});
Enter fullscreen mode Exit fullscreen mode

Peers that have been silent for too long are removed:

if (
  now - peer.lastSeen >
  PEER_TIMEOUT_MS
) {
  peers.delete(id);
}
Enter fullscreen mode Exit fullscreen mode

The visible count is then:

const count = peers.size + 1;
Enter fullscreen mode Exit fullscreen mode

The extra 1 represents the current tab.

This is obviously not a production-grade presence system, but for a small same-browser experiment it works well.

The channel can carry more than drawing data

Once the tabs can exchange structured messages, we can use the same channel for other actions.

For example, pressing Clear all tabs sends:

channel.postMessage({
  type: "clear",
  id: tabId
});
Enter fullscreen mode Exit fullscreen mode

Other tabs can react to it:

if (data.type === "clear") {
  clearCanvas(false);
}
Enter fullscreen mode Exit fullscreen mode

The channel effectively becomes a very small event bus.

The experiment uses several message types:

draw
clear
hello
heartbeat
bye
Enter fullscreen mode Exit fullscreen mode

Using objects with a type property makes it easy to extend the protocol later.

No WebSocket server required

The important distinction is that this communication happens between browser contexts belonging to the same origin.

Conceptually:

Tab A
   |
   | BroadcastChannel
   |
   +------> Tab B
   |
   +------> Tab C
Enter fullscreen mode Exit fullscreen mode

There is:

  • no backend endpoint receiving coordinates
  • no database
  • no WebSocket connection
  • no polling loop

That makes BroadcastChannel useful when multiple instances of the same web application need to coordinate inside the browser.

It is not a replacement for WebSockets.

If two users on different computers need to share the same drawing, a network service would still be necessary.

Same-origin matters

BroadcastChannel only connects compatible browsing contexts from the same origin.

A page running on:

https://example.com
Enter fullscreen mode Exit fullscreen mode

cannot simply exchange messages with:

https://another-example.com
Enter fullscreen mode Exit fullscreen mode

For this experiment, that is exactly the behavior we want.

When testing the project locally, I recommend serving the files through a small web server:

python3 -m http.server 8000
Enter fullscreen mode Exit fullscreen mode

Then open:

http://localhost:8000
Enter fullscreen mode Exit fullscreen mode

in two tabs.

On CodePen, the platform already serves the preview for us.

Avoiding unnecessary messages

Pointer events can fire very frequently.

Sending every microscopic movement would create unnecessary messages and almost invisible canvas segments.

Before broadcasting a new segment, the project measures the distance travelled:

const dx =
  (next.x - lastPoint.x) * width;

const dy =
  (next.y - lastPoint.y) * height;
Enter fullscreen mode Exit fullscreen mode

Very small movements are ignored:

if (Math.hypot(dx, dy) < 2) {
  return;
}
Enter fullscreen mode Exit fullscreen mode

It is a small optimization, but it reduces both drawing operations and cross-tab traffic without affecting the perceived smoothness.

BroadcastChannel is communication, not storage

Another important property of BroadcastChannel is that its messages are transient.

If Tab A sends a message while Tab B is listening, Tab B receives it.

If Tab C opens five seconds later, it does not receive a history of previous messages.

For this experiment that is fine because the neon trails intentionally disappear.

For an application that needs persistent state, I would separate the two responsibilities:

BroadcastChannel
→ live synchronization

IndexedDB / localStorage / server
→ persistence
Enter fullscreen mode Exit fullscreen mode

That distinction is useful in real applications too.

A small API with interesting possibilities

The actual BroadcastChannel API is tiny.

Create a channel:

const channel =
  new BroadcastChannel("my-channel");
Enter fullscreen mode Exit fullscreen mode

Send data:

channel.postMessage(data);
Enter fullscreen mode Exit fullscreen mode

Receive data:

channel.onmessage = event => {
  console.log(event.data);
};
Enter fullscreen mode Exit fullscreen mode

The interesting part is what you build around those operations.

The same mechanism can be used to synchronize:

  • authentication changes
  • application settings
  • cached data
  • notifications
  • editors
  • dashboards
  • application state

Or, as in this case, to make several browser tabs feel like parts of the same drawing surface.

Try it yourself

Here is the complete CodePen again:

Open it in two tabs and start drawing.


This article is a cross-post. It was originally published on CertosinoLab.

Top comments (0)