'Avoiding simultaneous promise, best practices?

I have an analytical dashboard. A dashboard is build out of one or more visualizations and upon initial rendering the dashboard I retrieve the minimal info required to render a visualization.

After that, I want to 'preload' additional information that enables me to interact in a more detailed way with each individual visualization. This information is stored in a schema. One or more visualizations can share the same schema.

Therefore I want to load the schema once, and after that return the cached schema to avoid multiple server requests.

The challenge I am solving is to avoid multiple visualizations, at the same time, from requesting this information. When one visualization is requesting the schema and we have multiple requesting the same schema, I don't want to invoke multiple server requests but have them wait for the first one to finish and then use the cached version.

What would be the best way to approach this? I could do something really nasty like keeping track of ongoing XHR requests and then do a setTimeout(retry,500); on all subsequent requests untill the first XHR request has finished... but maybe there is a way to restructure the promise in such a way to create a dependency between follow up requests on the same schema?

Any tips would be appreciated. I know how to fake it in an ugly way but I want to do it in the right way.

<script type="text/javascript">
    function SchemaManager() {
        this.schemas = {}; // precached schemas
    }
    SchemaManager.prototype = {
        Load: async function (schemaId) {
            this.schemas[schemaId] = "loading";
            return new Promise((resolve, reject) => {
                Xhr({
                    url: `/api/lightweightfield/${schemaId}`,
                    type: 'json',
                    action: 'GET',
                    callbackOk: function () { resolve(this); },
                    callbackError: function () { reject(); }
                });
            });
        },
        Get: async function (schemaId) {
            if (!this.schemas[schemaId]) {
                let result = await this.Load(schemaId).catch((e) => {
                    new ErrorDialog({ title: 'Failed to get schema', content: schemaId });
                    return null;
                });
                this.schemas[schemaId] = result.response;
            } else {
                console.log('cache hit');
            }
            return this.schemas[schemaId];
        }
    }

    let sm = new SchemaManager();
        
    (async function () {
        var schema = await sm.Get("schemas-1953-A");
        console.log('visualization 1',schema);
    })();
    (async function () {
        var schema = await sm.Get("schemas-1953-A");
        console.log('visualization 2', schema);
    })();
    (async function () {
        var schema = await sm.Get("schemas-1953-A");
        console.log('visualization 3', schema);
    })();
    (async function () {
        var schema = await sm.Get("schemas-1953-A");
        console.log('visualization 4', schema);
    })();
    (async function () {
        var schema = await sm.Get("schemas-1953-A");
        console.log('visualization 5',schema);
    })();

    
</script>


Solution 1:[1]

Basically, you want to serialize calls to Get. (I'd also probably make Load private so that Get is the only external API for getting schemas from SchemaManager.) You can do that by keeping track of the promise Get returns (for a given schema) and having the next call to Get for that same schema wait for the previous promise to settle before doing anything.

You've agreed in a comment with the idea that Load should be private, so I've moved it out of SchemaManagers's API.

Something along these lines (see *** comments):

function SchemaManager() {
    this.schemas = {}; // precached schemas
}
function loadSchema(schemaId) {
    console.log(`Loading ${schemaId}`);
    // *** Probably worth promise-enabling `Xhr` so we don't need a wrapper every time
    return new Promise((resolve, reject) => {
        Xhr({
            url: `/api/lightweightfield/${schemaId}`,
            type: "json",
            action: "GET",
            callbackOk: function() {
                console.log(`Loaded ${schemaId}`);
                resolve(this.response);
            },
            callbackError: (error) => { // *** I assume an error is provided?
                // *** Failed to load, so clear the promise from cache
                reject(error); // *** Pass along the error if provided
            }
        });
    });
}
SchemaManager.prototype = {
    Get: async function (schemaId) {
        let cacheEntry = this.schemas[schemaId];
        if (cacheEntry instanceof Promise) {
            // *** Previous load in progress, wait for it to complete
            try {
                console.log("Waiting for previous load");
                const schema = await cacheEntry;
                console.log("Using previous load result");
                return schema;
            } catch {
                // *** Previous load failed, try again
            }
        } else if (cacheEntry) {
            // *** The cache entry is a schema
            console.log("Cache hit");
            return cacheEntry;
        }
        // *** Need to load the schema
        console.log("Start load");
        cacheEntry = this.schemas[schemaId] = loadSchema(schemaId);
        try {
            const schema = await cacheEntry;
            console.log("Caching result for next time");
            this.schemas[schemaId] = schema;
            return schema;
        } catch {
            // *** Didn't get it
            this.schemas[schemaId] = null;
            throw error;
        }
    }
}

let sm = new SchemaManager();

(async function () {
    var schema = await sm.Get("schemas-1953-A");
    console.log("visualization 1",schema);
})();
(async function () {
    var schema = await sm.Get("schemas-1953-A");
    console.log("visualization 2", schema);
})();
(async function () {
    var schema = await sm.Get("schemas-1953-A");
    console.log("visualization 3", schema);
})();
(async function () {
    var schema = await sm.Get("schemas-1953-A");
    console.log("visualization 4", schema);
})();
(async function () {
    var schema = await sm.Get("schemas-1953-A");
    console.log("visualization 5",schema);
})();

Live Example:

// Stand-in for Xhr
function Xhr({url, callbackOk}) {
    setTimeout(() => {
        const n = url.lastIndexOf("/");
        const id = url.substring(n + 1);
        callbackOk.call({response: {id}});
    }, Math.random() * 100);
}
// Stand-in for ErrorDialog
function ErrorDialog({content}) {
    console.error(content);
}
function SchemaManager() {
    this.schemas = {}; // precached schemas
}
function loadSchema(schemaId) {
    console.log(`Loading ${schemaId}`);
    // *** Probably worth promise-enabling `Xhr` so we don't need a wrapper every time
    return new Promise((resolve, reject) => {
        Xhr({
            url: `/api/lightweightfield/${schemaId}`,
            type: "json",
            action: "GET",
            callbackOk: function() {
                console.log(`Loaded ${schemaId}`);
                resolve(this.response);
            },
            callbackError: (error) => { // *** I assume an error is provided?
                // *** Failed to load, so clear the promise from cache
                reject(error); // *** Pass along the error if provided
            }
        });
    });
}
SchemaManager.prototype = {
    Get: async function (schemaId) {
        let cacheEntry = this.schemas[schemaId];
        if (cacheEntry instanceof Promise) {
            // *** Previous load in progress, wait for it to complete
            try {
                console.log("Waiting for previous load");
                const schema = await cacheEntry;
                console.log("Using previous load result");
                return schema;
            } catch {
                // *** Previous load failed, try again
            }
        } else if (cacheEntry) {
            // *** The cache entry is a schema
            console.log("Cache hit");
            return cacheEntry;
        }
        // *** Need to load the schema
        console.log("Start load");
        cacheEntry = this.schemas[schemaId] = loadSchema(schemaId);
        try {
            const schema = await cacheEntry;
            console.log("Caching result for next time");
            this.schemas[schemaId] = schema;
            return schema;
        } catch {
            // *** Didn't get it
            this.schemas[schemaId] = null;
            throw error;
        }
    }
}

let sm = new SchemaManager();

(async function () {
    var schema = await sm.Get("schemas-1953-A");
    console.log("visualization 1",schema);
})();
(async function () {
    var schema = await sm.Get("schemas-1953-A");
    console.log("visualization 2", schema);
})();
(async function () {
    var schema = await sm.Get("schemas-1953-A");
    console.log("visualization 3", schema);
})();
(async function () {
    var schema = await sm.Get("schemas-1953-A");
    console.log("visualization 4", schema);
})();
(async function () {
    var schema = await sm.Get("schemas-1953-A");
    console.log("visualization 5",schema);
})();
.as-console-wrapper {
    max-height: 100% !important;
}

In that, I've set it up that Get tries the loadSchema again if the previous one failed. Alternatively, you might store some kind of "failed" object in the cache to indicate that the schema can't be loaded.

A couple of other minor notes:

  • The overwhelmingly-common convention in JavaScript code is that only constructor functions start with an upper case letter. So Load and Get would conventionally be written load and get.
  • Replacing the prototype property on a constructor function is not best practice, not least because you mess up the constructor property the object put there by the JavaScript engine set up. Instead, write to properties on it — or better yet, use class syntax.

Solution 2:[2]

This is a familiar issue…

You could try refactoring your methods so that:

  • this.schemas maps schema IDs to retrieval promises, not schema data.
  • Get() returns this.schemas[schemaId], populating it by calling Load() if needed.
  • Load() does not touch this.schemas at all, its responsibility is strictly to construct a query and return a promise of schema data. As a side-effect, it becomes easier to test. (If it were me I’d also switch to a functional pattern and move this function out of the manager, but that depends on your conventions.)

As a result, callers of Get() would receive the same single promise that should be resolved for all of them when request is complete. Later callers may receive an instantly-resolving promise.

Here’s a minimal sandbox demonstrating the scenario: https://codesandbox.io/s/compassionate-wu-x8vgl6?file=/src/index.js

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Anton Strogonoff