Drawing Across Browser Tabs with JavaScript, Canvas and BroadcastChannel


A few days ago I wrote about using the JavaScript BroadcastChannel API to keep multiple browser tabs synchronized.

The basic idea is surprisingly simple: pages from the same origin can create a channel with the same name and exchange messages directly in the browser.

There is no WebSocket connection, no polling loop and no server involved.

This time I wanted to turn that concept into something more visual.

Instead of synchronizing a text field or a counter, I built a small experiment where you can draw in one browser tab and see the same drawing appear immediately in another tab.

The result is a cross-tab neon drawing canvas built with:

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

No framework and no external library are required.

The complete experiment is available on CodePen:

Draw Across Browser Tabs ✦

Open the Pen in two browser tabs and start drawing in one of them. The second tab should follow the drawing almost instantly.

The basic idea

Each browser tab contains its own canvas and its own JavaScript execution context.

Normally, drawing something on a canvas in Tab A has absolutely no effect on the canvas displayed in Tab B.

We therefore need to send the drawing information from one tab to the others.

The communication channel is created with:

const channel = new BroadcastChannel(
  "cross-tab-neon-canvas-v1"
);

Every instance of the application creates a channel using exactly the same name.

When one tab wants to notify the others, it sends a message:

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

The other tabs can listen for that message:

channel.addEventListener("message", ({ data }) => {
  if (data.type === "draw") {
    // Draw the received point
  }
});

That is essentially the communication layer of the entire experiment.

The rest of the project is mostly about turning pointer movements into nice-looking canvas trails.

Sending line segments instead of pixels

A drawing application could theoretically send every individual pixel to the other tabs, but that would be unnecessary.

Instead, the application tracks the previous pointer position and the current one.

Those two coordinates define a small line segment:

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

The segment is first rendered locally:

addSegment(segment, color);

and then broadcast to the other tabs:

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

The receiving tabs simply add the same segment to their own animation:

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

There is no shared canvas.

Each tab independently renders its own canvas using the drawing instructions it receives.

Why the coordinates are normalized

There is an interesting problem here: the two browser tabs do not necessarily have the same dimensions.

For example, one tab could have a canvas that is 1400 pixels wide while another window could only be 900 pixels wide.

Sending absolute coordinates such as:

{
  x: 1050,
  y: 420
}

would make the drawing appear in a different relative position.

Instead, the application converts pointer coordinates 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
  };
}

A point in the middle of the screen therefore becomes approximately:

{
  x: 0.5,
  y: 0.5
}

When rendering the point, each browser converts it back to its own canvas dimensions:

const x = segment.x1 * width;
const y = segment.y1 * height;

This means that the drawing remains in approximately the same visual position even when the tabs have different sizes.

Creating the neon trail

The canvas does not permanently store the drawing.

Instead, every segment remembers when it was created:

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

The animation loop continuously checks its age:

const age = now - segment.born;

const life =
  1 - age / FADE_MS;

As life approaches zero, the segment gradually disappears.

Old segments are eventually removed completely:

if (age > FADE_MS) {
  return false;
}

This produces a temporary light trail rather than a traditional persistent drawing.

The animation itself runs with:

requestAnimationFrame(render);

Each line is rendered twice.

The first pass creates a large transparent glow:

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

The second pass draws a much brighter core:

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

ctx.lineWidth = segment.width;
ctx.shadowBlur = 12 * life;

With the canvas composite mode set to:

ctx.globalCompositeOperation = "lighter";

overlapping strokes behave a little more like light.

Small particles are also generated around the drawing position to make the interaction feel less static.

Giving every tab its own color

Every browser tab receives its own identifier:

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

That identifier is converted into an index inside a small color palette:

const palette = [
  "#7CFFB2",
  "#7DD3FC",
  "#C084FC",
  "#F9A8D4",
  "#FDE68A"
];

The tab therefore gets its own drawing color.

When a line segment is broadcast, its color travels with it:

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

This allows multiple tabs to contribute different colors to the same visual composition.

Detecting the other open tabs

BroadcastChannel does not provide a built-in method for getting a list of connected tabs.

If we want to display something like:

3 tabs connected

we need to build that logic ourselves.

In this experiment, every tab periodically sends a heartbeat:

setInterval(() => {
  send("heartbeat");
  prunePeers();
}, HEARTBEAT_MS);

When another tab receives a message, it updates the last time that peer was seen:

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

If a peer remains silent for too long, it is removed:

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

The number of visible tabs is then simply:

const count = peers.size + 1;

The + 1 represents the current tab.

This is not meant to be a distributed presence system. It is simply a small browser-side mechanism that works well enough for this experiment.

Synchronizing other actions

Once the communication channel exists, drawing is not the only event we can send.

For example, the Clear all tabs button broadcasts another message type:

channel.postMessage({
  type: "clear",
  id: tabId
});

The other tabs react accordingly:

if (data.type === "clear") {
  clearCanvas(false);
}

The same channel therefore acts as a small event bus.

Messages can represent completely different actions:

draw
clear
hello
heartbeat
bye

This is one of the reasons why sending structured objects through BroadcastChannel is useful.

Instead of broadcasting a raw value, each message contains a type describing what happened.

No server synchronization

One of the interesting parts of this demo is what it does not contain.

There is no backend endpoint receiving pointer coordinates.

There is no database.

There is no WebSocket server.

There is no polling request.

Communication happens directly between browser contexts.

Conceptually, the flow is:

Tab A
   |
   | BroadcastChannel
   |
   +------> Tab B
   |
   +------> Tab C

For communication between instances of the same web application on the same origin, this can be considerably simpler than introducing server-side infrastructure.

Of course, BroadcastChannel is not a replacement for WebSockets.

If two users on different computers need to see the same drawing, the messages need to travel through a network service.

This experiment only needs to communicate between browser tabs belonging to the same user and origin.

The same-origin requirement

There is an important restriction.

BroadcastChannel communication works between browsing contexts belonging to the same origin.

A page running on:

https://example.com

cannot simply communicate through BroadcastChannel with:

https://another-example.com

This restriction is exactly what we normally want when multiple instances of the same web application need to communicate.

When testing the project locally, it is better to serve it through a small local web server instead of opening the HTML file directly.

For example:

python3 -m http.server 8000

Then open:

http://localhost:8000

in two browser tabs.

On CodePen, the platform takes care of serving the preview, so no additional server is required.

A small performance optimization

Pointer events can fire very frequently.

Broadcasting every microscopic movement would create many messages and many almost invisible line segments.

Before sending a new segment, the project calculates how far the pointer moved:

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

const dy =
  (next.y - lastPoint.y) * height;

Very small movements are ignored:

if (Math.hypot(dx, dy) < 2) {
  return;
}

This reduces unnecessary drawing operations and cross-tab messages while keeping the animation visually smooth.

It is a small optimization, but interactive experiments like this are a good place to think about how frequently browser events are generated.

BroadcastChannel is still not storage

The disappearing trail also illustrates an important characteristic of BroadcastChannel.

Messages are transient.

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

If Tab C is opened five seconds later, it does not receive a history of everything that was previously drawn.

BroadcastChannel does not store messages.

For this experiment, that behavior is perfectly fine because the trail intentionally disappears.

For an application where state must survive refreshes or newly opened tabs, another storage mechanism would be necessary.

For example:

BroadcastChannel
→ live synchronization

IndexedDB / localStorage / server
→ persistence

Keeping these two responsibilities separate often makes browser applications easier to reason about.

From a small API to a visual experiment

The interesting thing about BroadcastChannel is not its complexity. The API itself is tiny.

Create a channel:

const channel =
  new BroadcastChannel("my-channel");

Send something:

channel.postMessage(data);

Receive something:

channel.onmessage = event => {
  console.log(event.data);
};

The interesting part is what can be built around those three operations.

A synchronized note editor is one possibility. Keeping authentication state aligned between tabs is another. Refreshing cached data when another tab changes something is another.

And occasionally, the same API can simply become the foundation of a small visual experiment.

In this case, a few messages exchanged between browser tabs are enough to make multiple independent canvases behave as if they were part of the same drawing surface.

Try it

You can find the complete experiment on CodePen:

Draw Across Browser Tabs ✦

Open it twice, start drawing in one tab and watch the other one follow.

Comments

Popular posts from this blog

Building a Linear Regression PWA with React

Building AI Prompt Lab with Java 21, Spring Boot and React 19

Keep the Screen Awake with JavaScript: The Screen Wake Lock API