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
BroadcastChannelrequestAnimationFrame- 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"
);
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
});
And the other tabs can listen for it:
channel.addEventListener("message", ({ data }) => {
if (data.type === "draw") {
// Render the received drawing data
}
});
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
};
The segment is rendered locally:
addSegment(segment, color);
and then sent to the other tabs:
channel.postMessage({
type: "draw",
id: tabId,
color,
segment
});
A receiving tab simply renders the same segment on its own canvas:
if (data.type === "draw") {
addSegment(
data.segment,
data.color,
true
);
}
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
}
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
};
}
The middle of the canvas is therefore approximately:
{
x: 0.5,
y: 0.5
}
Each receiving tab converts those values back to its own dimensions:
const x = segment.x1 * width;
const y = segment.y1 * height;
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()
});
Inside the animation loop, its age determines its opacity:
const age = now - segment.born;
const life =
1 - age / FADE_MS;
Once the segment becomes too old, it is removed:
if (age > FADE_MS) {
return false;
}
The animation runs through:
requestAnimationFrame(render);
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;
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;
I also use:
ctx.globalCompositeOperation = "lighter";
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);
That identifier is mapped to a small palette:
const palette = [
"#7CFFB2",
"#7DD3FC",
"#C084FC",
"#F9A8D4",
"#FDE68A"
];
When a drawing segment is broadcast, its color is sent with it:
channel.postMessage({
type: "draw",
id: tabId,
color,
segment
});
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();
To display a small indicator such as:
3 tabs connected
the application implements a basic presence mechanism.
Each tab periodically sends a heartbeat:
setInterval(() => {
send("heartbeat");
prunePeers();
}, HEARTBEAT_MS);
Whenever another tab sends a message, its last activity time is stored:
peers.set(id, {
lastSeen: Date.now(),
color: peerColor
});
Peers that have been silent for too long are removed:
if (
now - peer.lastSeen >
PEER_TIMEOUT_MS
) {
peers.delete(id);
}
The visible count is then:
const count = peers.size + 1;
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
});
Other tabs can react to it:
if (data.type === "clear") {
clearCanvas(false);
}
The channel effectively becomes a very small event bus.
The experiment uses several message types:
draw
clear
hello
heartbeat
bye
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
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
cannot simply exchange messages with:
https://another-example.com
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
Then open:
http://localhost:8000
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;
Very small movements are ignored:
if (Math.hypot(dx, dy) < 2) {
return;
}
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
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");
Send data:
channel.postMessage(data);
Receive data:
channel.onmessage = event => {
console.log(event.data);
};
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)