A Lazy Sequence

Accessing Local Storage from a Web Worker

A curious detail of the Web Workers and Web Storage specs is that the storage objects are not accessible from within a worker context: only the main UI process can access the stores1.

This limitation may or may not be a problem for you. In an app I am building I wanted to be able to consolidate all my data access and processing to a worker so I’ve produced a (somewhat obvious when you are familiar with the Worker API) workaround:

  1. Create your worker like normal.
  2. Create a MessageChannel instance
  3. In your main thread process a wrapper around the Local Storage API and events that communicates via one of the MessagePort instances on the MessageChannel instance.
  4. Send a message, along with the other MessagePort from the channel created in 2, to the worker telling it to use the provided port as a proxy for LocalStorage.

There are two obvious downsides with this approach: It’s certainly going to be slightly slower, and it means the storage access (in the worker) will lose the convenient synchronous API.

Pseudocode

Due to the nature of the Worker API its a little fussy to make a clean workable example of this code, but here is a pseudocode version (ripped from Manticore).

The storage wrapper

This function lives within your main thread.

function localStoragePort() {
    var chan = new MessageChannel();
	var localPort = chan.port1;

    window.addEventListener('storage', function(e) {
        if (e.key) {
            localPort.postMessage({kind: "storage.data", key: e.key, value: e.newValue});
        }
    });             
        
    localPort.onmessage = (ev) => {
        var message = ev.data;

        if (message.kind === "storage.get") {
            var val = localStorage.getItem(message.key)
            if (message.defaultValue !== undefined && val === null) {
                val = message.defaultValue;
            }
                
            localPort.postMessage({
                kind: "storage.data",
                key: message.key, 
                value: val
            });
        }
        else if (message.kind === "storage.put") {
            localStorage.setItem(message.key, message.value);
            localPort.postMessage({
                kind: "storage.data",
                key: message.key,
                value: localStorage.getItem(message.key)
            }); 
        }      
        else if (message.kind === "storage.delete") {
            localStorage.removeItem(message.key);
            localPort.postMessage({
                kind: "storage.data", 
                key: message.key,
                value: null
            });
        }  
    }
        
    return chan.port2
}

You can then tell the worker to use the port returned from localStoragePort() with something like:


worker.postMessage({kind: "lifecycle.register-storage-port"}, [localStoragePort()]);

Note that the MessagePort is passed in as a transferrable in the second argument to postMessage. If you try to pass it in as part of the message itself you will get a error telling you that the data cannot be cloned. Once you pass the transferrable object through you will lose the ability to access it on the posting side of the interface.

Worker port usage

Finally you have to listen for this lifecycle message in the worker and when you receive it, store a reference to the port. Ideally you would wrap this up in a cleaner object than just working directly but for the purposes of explaining what is going on:

var storagePort;

function registerStoragePort(port) {
    if (storagePort) {
        storagePort();
    }

    storagePort = port;
    storagePort = function (message) {
        var data = message.data;
	    // This will be a "storage.data" message returned from the port.
        // Do something with data.value 
    }
}

onmessage = function (ev) {
    var message = ev.data;
    if (message.kind === "lifecycle.register-storage-port") {
        registerStoragePort(ev.ports[0]);
    }
    else {
        // ...
    }
};

Sending messages to port is the final step:

storagePort.postMessage({kind: "storage.put", key: "foo", value: "Bar"});
storagePort.postMessage({kind: "storage.get", key: "foo", defaultValue: "Quux"});

It is left as an exercise to the reader to use per message MessagePort instances with the storagePort to ensure that the result of a request is handled in a specific location rather than globally.

Footnotes

  1. If anyone can explain the reasoning for this I would love to know.