DEV Community

Cover image for Sharing a state between windows without a server
notachraf
notachraf

Posted on • Updated on • Originally published at Medium

Sharing a state between windows without a server

Recently, there was a gif trending on social networks displaying an amazing piece of art made by Bjorn Staal.

Bjorn Staal art piece

I wanted to recreate it, but lacking the 3D skills for the sphere, particles, and physics, I aimed to understand how to make a window react to the position of another window.

Essentially, sharing a state between multiple windows, which I find to be one of the coolest aspects of Bjorn’s project!
Unable to find a good article or tutorial on the topic, I decided to share my findings with you.

Let’s attempt to create a simplified Proof of Concept (POC) based on Bjorn’s work!

What we’ll try to create ( ofc it’s way less sexy than Bjorn’s work )

The first thing I did was to list all the ways I know for sharing information between multiple clients:

Duh: A server

Obviously, having a server (either with polling or websockets) would simplify the problem. However, since Bjorn achieved his result without using a server, this was out of the question.

Local Storage

Local Storage is essentially a browser key-value store, commonly used for persisting information between browser sessions. While typically used for storing Auth Tokens or Redirect URLs, it can store anything serializable. You can learn more about it here.

I recently discovered some fun APIs of Local Storage, including the storage event, which fires whenever the Local Storage is changed by another session of the same website.

Wanna Discover new APIs ?
Subscribe to my Newsletter ( for free ! )

How the storage event works ( simplified of course )

We can leverage this by storing the state of each window in the local storage. Whenever a window changes its state, other windows will be updated via the storage event.

This was my initial idea, and it seems to be the solution Bjorn chose, as he shared his LocalStorage manager code along with an example of using it with threeJs here.

But once I found out that there was code solving this problem, I wanted to see if there was another way… and spoiler alert: Yes, there is!

Shared Workers

Behind this flashy terminology is a fascinating concept — the concept of WebWorkers.

In simple terms, a worker is essentially a second script running on another thread. While they don’t have access to the DOM as they exist outside the HTML Document, they can still communicate with your main script.
They are mostly used to offload the main script by handling background jobs, such as pre-fetching information or handling less critical tasks like streaming logs and polling.

Simplified explanation of the mechanisms of communication between a script and a worker

Shared workers are a special kind of WebWorkers that can communicate with multiple instances of the same script, making them interesting for our use case! Okay, let’s dive right into the code!

Shared workers can send information to multiple sessions of the same script

Setting up the worker

As mentioned, workers are a “second script” with their own entry points. Depending on your setup (TypeScript, bundler, development server), you may need to tweak your tsconfig, add directives, or use specific import syntax.

I can’t cover all the possible ways to use a web worker , but you can find the informations on MDN or the internet.
If needed, I’d happily do a prequel to this article detailing all the ways to set them up!

In my case, I’m using Vite and TypeScript, so I need a worker.ts file and installing the @types/sharedworker as a dev dependency. We can create our connection in our main script using this syntax:

new SharedWorker(new URL("worker.ts", import.meta.url));
Enter fullscreen mode Exit fullscreen mode

Basically, we need to:

  • Identify each window

  • Keep track of all window states

  • Alert other windows to redraw once a window changes its state

Our state will be quite simple:

type WindowState = {
      screenX: number; // window.screenX
      screenY: number; // window.screenY
      width: number; // window.innerWidth
      height: number; // window.innerHeight
};
Enter fullscreen mode Exit fullscreen mode

The most crucial information is, of course, window.screenX and window.screenY as they tell us where the window is relative to the top-left corner of your monitor.

We’ll have two types of messages:

  • Each window, whenever it changes its state, will publish a windowStateChangedmessage with its new state.

  • The worker will send updates to all other windows to alert them that one of them has changed. The worker will send a syncmessage with the state of all windows.

We can start with a plain worker looking a bit like this:

    // worker.ts 
    let windows: { windowState: WindowState; id: number; port: MessagePort }[] = [];

    onconnect = ({ ports }) => {
      const port = ports[0];

      port.onmessage = function (event: MessageEvent<WorkerMessage>) {
        console.log("We'll do something");
      };
    };
Enter fullscreen mode Exit fullscreen mode

And our basic connection to the SharedWorker will look something like this. I have some basic functions that will generate an id, and calculate the current window state, also I did some typing on the kind of Message that we can use called WorkerMessage:

    // main.ts
    import { WorkerMessage } from "./types";
    import {
      generateId,
      getCurrentWindowState,
    } from "./windowState";

    const sharedWorker = new SharedWorker(new URL("worker.ts", import.meta.url));
    let currentWindow = getCurrentWindowState();
    let id = generateId();
Enter fullscreen mode Exit fullscreen mode

Once we start the application, we should alert the worker that there is a new window, so we send immediately a message:

    // main.ts 
    sharedWorker.port.postMessage({
      action: "windowStateChanged",
      payload: {
        id,
        newWindow: currentWindow,
      },
    } satisfies WorkerMessage);
Enter fullscreen mode Exit fullscreen mode

We can listen to this message on our worker side and change the onmessage accordingly. Basically, once the worker receives the windowStateChanged message, either it's a new window, and we append it to the state, or it's an old one that changed. Then we should alert everybody that the state has changed:

    // worker.ts
    port.onmessage = function (event: MessageEvent<WorkerMessage>) {
      const msg = event.data;
      switch (msg.action) {
        case "windowStateChanged": {
          const { id, newWindow } = msg.payload;
          const oldWindowIndex = windows.findIndex((w) => w.id === id);
          if (oldWindowIndex !== -1) {
            // old one changed
            windows[oldWindowIndex].windowState = newWindow;
          } else {
            // new window 
            windows.push({ id, windowState: newWindow, port });
          }
          windows.forEach((w) =>
            // send sync here 
          );
          break;
        }
      }
    };
Enter fullscreen mode Exit fullscreen mode

To send the sync, I actually need a bit of a hack, because the “port” property cannot be serialized, so I stringify it and parse it back. Because I’m lazy and I don’t just map the windows to a more serializable array:

    w.port.postMessage({
      action: "sync",
      payload: { allWindows: JSON.parse(JSON.stringify(windows)) },
    } satisfies WorkerMessage);
Enter fullscreen mode Exit fullscreen mode

Now it’s time to draw stuff!

The Fun Part : Drawing !

Of course, we won’t be doing complicated 3D spheres : we’ll just draw a circle in the center of each window and a line linking between the spheres!

I’ll be using the basic 2D Context of the HTML Canvas to draw, but you can use whatever you want. To draw a circle, it’s pretty simple:

    const drawCenterCircle = (ctx: CanvasRenderingContext2D, center: Coordinates) => {
      const { x, y } = center;
      ctx.strokeStyle = "#eeeeee";
      ctx.lineWidth = 10;
      ctx.beginPath();
      ctx.arc(x, y, 100, 0, Math.PI * 2, false);
      ctx.stroke();
      ctx.closePath();
    };
Enter fullscreen mode Exit fullscreen mode

And to draw the lines, we need to do a bit of math (I promise, it’s not a lot 🤓) by converting the relative position of the center of another window to coordinates on our current window.
Basically, we are changing bases. I do this using this bit of math. First, we will change the base to have coordinates on the monitor and offset that by the current window screenX/screenY

Basically we are looking for the target position after base change

    const baseChange = ({
      currentWindowOffset,
      targetWindowOffset,
      targetPosition,
    }: {
      currentWindowOffset: Coordinates;
      targetWindowOffset: Coordinates;
      targetPosition: Coordinates;
    }) => {
      const monitorCoordinate = {
        x: targetPosition.x + targetWindowOffset.x,
        y: targetPosition.y + targetWindowOffset.y,
      };

      const currentWindowCoordinate = {
        x: monitorCoordinate.x - currentWindowOffset.x,
        y: monitorCoordinate.y - currentWindowOffset.y,
      };

      return currentWindowCoordinate;
    };
Enter fullscreen mode Exit fullscreen mode

And as you know, now we have two points on the same relative coordinates system, we can now draw the line !

    const drawConnectingLine = ({
      ctx,
      hostWindow,
      targetWindow,
    }: {
      ctx: CanvasRenderingContext2D;
      hostWindow: WindowState;
      targetWindow: WindowState;
    }) => {
      ctx.strokeStyle = "#ff0000";
      ctx.lineCap = "round";
      const currentWindowOffset: Coordinates = {
        x: hostWindow.screenX,
        y: hostWindow.screenY,
      };
      const targetWindowOffset: Coordinates = {
        x: targetWindow.screenX,
        y: targetWindow.screenY,
      };

      const origin = getWindowCenter(hostWindow);
      const target = getWindowCenter(targetWindow);

      const targetWithBaseChange = baseChange({
        currentWindowOffset,
        targetWindowOffset,
        targetPosition: target,
      });

      ctx.strokeStyle = "#ff0000";
      ctx.lineCap = "round";
      ctx.beginPath();
      ctx.moveTo(origin.x, origin.y);
      ctx.lineTo(targetWithBaseChange.x, targetWithBaseChange.y);
      ctx.stroke();
      ctx.closePath();
    };
Enter fullscreen mode Exit fullscreen mode

And now, we just need to react to state changes.

    // main.ts
    sharedWorker.port.onmessage = (event: MessageEvent<WorkerMessage>) => {
        const msg = event.data;
        switch (msg.action) {
          case "sync": {
            const windows = msg.payload.allWindows;
            ctx.reset();
            drawMainCircle(ctx, center);
            windows
              .forEach(({ windowState: targetWindow }) => {
                drawConnectingLine({
                  ctx,
                  hostWindow: currentWindow,
                  targetWindow,
                });
              });
          }
        }
    };
Enter fullscreen mode Exit fullscreen mode

And as a final step, we just need to periodically check if our window changed and send a message if that’s the case

      setInterval(() => {
        const newWindow = getCurrentWindowState();
        if (
          didWindowChange({
            newWindow,
            oldWindow: currentWindow,
          })
        ) {
          sharedWorker.port.postMessage({
            action: "windowStateChanged",
            payload: {
              id,
              newWindow,
            },
          } satisfies WorkerMessage);
          currentWindow = newWindow;
        }
      }, 100);
Enter fullscreen mode Exit fullscreen mode

You can find the whole code for this on this repository. I actually made it a bit more abstract as I did a lot of experiments with it, but the gist of it is the same.

And if you run it on multiple windows, hopefully, you can get the same thing as this!

The full result

Thanks for reading !

If you found this article helpful, intersting or just fun, you can share it to your friends/coworkers/community
You can also subscribe to my newsletter It's free !

The Degenerate Engineer | Achraf | Substack

caffeine fueled braindead engineer talking about basically 60% Tech / 30% Human interactions / 10% internet lore. Click to read The Degenerate Engineer, by Achraf, a Substack publication with hundreds of subscribers.

favicon notachraf.substack.com

Edit:

Some of you proposed another solution to this problem, namely using BroadcastChannel API and I wanted to give them a shoutout, mainly @framemuse and @axiol
Actually @axiol did a full write up of the solution using the BroadcastChannel API that you can find here: https://github.com/Axiol/linked-windows-broadcast-api

Huge thanks to them to help everyone else learn something new (starting from me)

Top comments (51)

Collapse
 
framemuse profile image
Valery Zinchenko

You probably should use BroadcastChannel API for this purpose instead of ServiceWorker, not only because it serves the purpose better, but also in spite of perfomance.

Accordingly to this article, you may gain some performance by using BroadcastChannel if you send small amount of data per message:
hacks.mozilla.org/2015/07/how-fast...

This is my little research, I didn't test it, so let me know if I'm wrong.

Collapse
 
notachraf profile image
notachraf

Oh ! I didn't know about this API, thanks for sharing it !

tbh i didn't really care about the performance here , but that sounds clearly like a better tool for this ( as the SharedWorker only re-broadcast on each sync )
Thanks for the finding, i'll try it out !

Collapse
 
jorensm profile image
JorensM • Edited

I recently wrote about this topic also, but about sharing state between tabs instead of windows (but same concept). What I found is that BroadcastChannel is not sufficient for this, because there is no straightforward way to sync the state when the window is first opened.

Collapse
 
framemuse profile image
Valery Zinchenko

What straightforward means in this case?

Thread Thread
 
jorensm profile image
JorensM

Well think for a second for a way how you could possibly get initial state with BroadcastChannel. One way would be to have a 'get_state' message, upon which each channel responds with a 'set_state' message. But this would result in every single channel sending out a message, which is not optimal and could even be less performant than a shared worker. If it were possible to have communication between exclusively 2 channels then it would be different though.

Thread Thread
 
framemuse profile image
Valery Zinchenko

That's just one request at the time connection happens, comparing to the fact that 100 messages per second could be sent while moving windows around, this one time connection ping-pong is nothing.

You can also cache the state in a localStorage, so when any new connection appears, it can be simply read from it (though this will require using JSON.parse()).

So why do you think this is a problem? Maybe performance one, but setup load time is tolerable since users usually don't get mad when something loading for the first time.

Collapse
 
martoxdlol profile image
Martoxdlol

BroadcastChannels are really nice. I used it for sharing data across multiple tabs and it was great. Not only you can send small messages but complex objects with circular references and al lot of information really fast (much faster than serializing it to json and back).

Collapse
 
quangcuong0808 profile image
Tran Cuong

This is what I was looking for in the article. But the article also mentioned several ways to share state and they are interesting either.

Collapse
 
link2twenty profile image
Andrew Bone

Great article ☺️

Did you know when storage is updated by another tab it fires an event? Which will save you needing to poll.

Here is a version I made
github.com/Link2Twenty/spyglass

Here is the local storage hook I used (and wrote)
github.com/bloc-digital/uselocalst...

Collapse
 
crisz profile image
crisz

I've seen this in a Chrome Experiment something like 10 years ago, and since then I have been wondering how it was possible. This article explains everything, thank you

Collapse
 
notachraf profile image
notachraf

Oh, If you still have the link of the chrome experiment I'd be grateful ! But yeah I basically saw the gif from Bjorn and I was mesmerized !

If you found this article interesting, don't hesitate to share it with your friends/coworkers/community ! ( you can also subscribe to my newsletter notachraf.substack.com/ ! )

Collapse
 
crisz profile image
crisz • Edited

Now that I remember it was different, it was a ball running across windows.

Here is the link: experiments.withgoogle.com/browser...

The experiment is not launchable but there are videos, that's pretty cool!

Thread Thread
 
notachraf profile image
notachraf

ohhh that's fun, maybe i'll try recreating something like that ! thanks for sharing !

Thread Thread
 
crisz profile image
crisz

I think you can contact Mark Mahoney personally to have the source code. It was available a time ago

Collapse
 
michaeltharrington profile image
Michael Tharrington

Wow!!! This is so freaking cool. Appreciate you sharing.

I really like the bouncing ball experiment that @crisz brought up elsewhere in thread. I can imagine a lot of interesting ideas based on this concept...

For instance, I'd love to see some sort of a virtual ant farm with ants crawling between overlapped windows. Or maybe a program for making digital collages. What if one window was a magnifying glass that you could pull over another window?

I'm thinking there's gotta be some gaming potential here too.

You got my imagination running with this one! Very cool stuff. 😀

Collapse
 
notachraf profile image
notachraf

Thanks for the comment ! Happy to have sparkled some ideas ! I'd love to see what you do with this !

I actually imagined a game where one window is a maze, and the other window is a source of light, so you need to move one window to illuminate the maze and navigate it ( never had the time to actually finish it )

but yeah, feel free to share whatever you do with this ! ( and my source code is ofc open source, so if you need a starting block you can use it directly ! )

Collapse
 
framemuse profile image
Valery Zinchenko • Edited

If thinking about smoothness of actions, you probably should use requestAnimationFrame to long-poll localStorage for updates, this should give a user the best experience.

Collapse
 
notachraf profile image
notachraf

yeah you're absolutly right i think, I actually had a version using requestAnimationFrame, but I was worried it introduced too many concepts in the same article , but in more advanced projects ( like the one in 3D ) clearly the best thing to do is to schedule the polling in the next microTask ( and not a task like setTimeout )

Collapse
 
framemuse profile image
Valery Zinchenko

I don't think that's a problem for such articles, they are to demonstrate an approach and methods used to deal with problems.

Collapse
 
kosm profile image
Kos-M • Edited

you could incorporate this into a lib that enables this to work over the internet without using a server using p2p networks.
it needs a unique string to connect all nodes in same "room" then all nodes can exchange messages.So you can render position of all connected to the room nodes.
Overcoming the limitation of localhost only..

bugout

Collapse
 
notachraf profile image
notachraf • Edited

oh that's actually a really clever idea, I never deepdived onto the WebRTC API , I'll try it out and maybe write an article about it !

Collapse
 
axiol profile image
Arnaud Delante • Edited

I made a quick test to adapt your logic with the use of BroadcastChannel API here github.com/Axiol/linked-windows-br...

It's a first draft, lots of things can probably be improved, but it works

Collapse
 
notachraf profile image
notachraf

Damn that's impressive! Great work! It's way more compact, I think that's the most adapted way of doing it, do you mind if I edit the article to showcase and link to your work?

Collapse
 
axiol profile image
Arnaud Delante

Sure, no problem. I'll see if I have to time to clean everything a bit

Collapse
 
artxe2 profile image
Yeom suyun

It's quite fascinating.
I imagined sharing a single WebSocket or SSE connection across multiple windows using SharedWorker, but unfortunately, it's not supported in Android Chrome, which is disappointing.

Collapse
 
notachraf profile image
notachraf

Oh, that's a great application of SharedWorkers actually, a lot of companies put their caching logic inside of SharedWorkers

I didn't know it was not yet supported on Android, that's a bummer :(

btw if you don't want to miss anything, you can also subscribe for my newsletter, it's free : notachraf.substack.com/ !

Collapse
 
efpage profile image
Eckehard

Inspiring post, thank you!

I was wandering if there are any solutions to share state between multiple clients or even between client and server (which would need to distinguish multiple running sessions). Might be simple if you can accept a low timing, but what, if you want a fast response time?

Collapse
 
best_codes profile image
Best Codes

Awesome article! Very good an interesting content.

Collapse
 
notachraf profile image
notachraf

Thanks ! I'll be dropping more in the next coming weeks, if you don't to miss anything, you can also subscribe for my newsletter, it's free : notachraf.substack.com/ !

Collapse
 
best_codes profile image
Best Codes

Sure!

Some comments have been hidden by the post's author - find out more