skip to Main Content

I’m looking for a way to do in the browser what Memcached offers, i.e. ability to configure a size limit (e.g. the localStorage quota) and it will evict old items automatically to keep the cache under limit.

The benefit is no explicit deletions are ever needed, as long as the cache keys are versioned/timestamped. I’ve seen some libraries that do similar with a “number of items” limit, but size limit would be more useful to stay under the browser’s quota.

2

Answers


  1. This cannot be implemented perfectly, because there are no guarantees about how browsers store the content of the local storage but a close-enough implementation can be created.

    We can start from the fact that a string in JS is a 16-bit unsigned integer value (because everything is stored as string in the localStorage). This means we can easily get the size of any string in bytes with content.length * 16 / 8.

    So now we only need to create a cache which tracks the size of the stored content and the size of the keys, it stores the content under.

    A very primitive and naive implementation could be:

    class Cache {
    
      private keyStoreKey: string;
      private cacheSizeKey: string;
    
      /**
       * List of the keys stored in this cache.
       */
      private storedKeys: string[] = [];
    
      /**
       * The size of the stored values in bytes
       */
      private cacheSize: number = 0;
    
      constructor(
        private prefix: string, 
        private sizeLimit: number,
      ) {
        if(this.sizeLimit < 100) {
          // the minimal size of the cache is 24 + prefix, otherwise, it would enter 
          // a loop trying to remove every element on any insert operation.
          throw new Error('Cache size must be above 100 bytes');
        }
    
        this.keyStoreKey = `${prefix}:k`;
        this.cacheSizeKey = `${prefix}:s`;
    
        this.cacheSize = localStorage.getItem(this.cacheSizeKey) ? Number.parseInt(localStorage.getItem(this.cacheSizeKey)) : 0;
        this.storedKeys = localStorage.getItem(this.keyStoreKey) ? localStorage.getItem(this.keyStoreKey).split(',') : [];
      }
    
      /**
       * The size of the keys in bytes
       */
      public get keyStoreSize() {
        return this.calculateLenght(this.storedKeys.join(`${this.prefix}:v:,`));
      }
    
      public get totalUsedSize() {
        return this.cacheSize + this.keyStoreSize + this.calculateLenght(this.keyStoreKey) + this.calculateLenght(this.cacheSizeKey);
      }
    
      /**
       * Returns the size of the given string in bytes.
       * 
       * The ECMAScript specification defines character as single 16-bit unit of UTF-16 text
       */
      private calculateLenght(content: string): number {
        return content.length * 16 / 8;
      }
    
      /**
       * Saves an item into the cahce.
       * 
       * NOTE: name cannot contain commas.
       */
      public set(name: string, content: string) {
        const newContentSize = this.calculateLenght(content);
    
        if(!(this.storedKeys).some(storedName => storedName === name)) {
          this.storedKeys.unshift(name);
    
          this.cacheSize += newContentSize;
        } else {
          this.storedKeys = this.storedKeys.filter(n => n !== name);
          this.storedKeys.unshift(name);      
    
          const oldContentSize = this.calculateLenght(localStorage.getItem(`${this.prefix}:v:${name}`));
    
          this.cacheSize = this.cacheSize - oldContentSize + newContentSize;
        }
    
        while(this.totalUsedSize > this.sizeLimit && this.storedKeys.length > 0) {
          this.removeOldestItem();
        }
    
        localStorage.setItem(this.cacheSizeKey, this.cacheSize.toString());  
        localStorage.setItem(`${this.prefix}:debug:totalSize`, this.totalUsedSize.toString());  
        localStorage.setItem(this.keyStoreKey, this.storedKeys.join(','));
        localStorage.setItem(`${this.prefix}:v:${name}`, content);
      }
    
      /**
       * The oldest item is the last in the stored keys array
       */
      private removeOldestItem() {
        const name = this.storedKeys.pop();
        const oldContentSize = this.calculateLenght(localStorage.getItem(`${this.prefix}:v:${name}`));
    
        this.cacheSize -= oldContentSize;
    
        localStorage.removeItem(`${this.prefix}:v:${name}`);
      }
    }
    
    window['cache'] = new Cache('test', 200);
    

    I haven’t implemented the functions for reading data, but because the keys are stored in an array you can easily implement a getMostRecent() or getNthRecent(position) or just a simple get(key) function.

    The implementation is in Typescript if you are not familiar with it just ignore the unknown parts.

    Login or Signup to reply.
  2. If you want to use a solution that already exists for this functionality then you can check out this library runtime-memcache implements lru and a few other caching schemes(mru, timeout) in javascript.

    It uses modified Doubly Linked List to achieve O(1) for get, set and remove.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search