Bug 1406181 - Use indexedDB as the backend for storage.local. draft
authorLuca Greco <lgreco@mozilla.com>
Thu, 19 Oct 2017 14:10:11 +0200
changeset 714402 855986dc90db7d22545f607d0233d1b975868488
parent 714401 1606d2dc8b751a93e1ee0c8e6826c3742666d7d0
child 714403 3267a39ea366189ae1618fa316c4dbdf26690088
child 714409 d62228330009a75a98349900c9b10524ed8a1b9d
push id93913
push userluca.greco@alcacoop.it
push dateFri, 22 Dec 2017 19:01:26 +0000
bugs1406181
milestone59.0a1
Bug 1406181 - Use indexedDB as the backend for storage.local. This patch integrates the new ExtensionStorageIDB.jsm into the storage API modules (ext-storage.js and ext-c-storage.js). ## storage.local backend selection The IDB backend is enabled by a preference ("extensions.webextensions.useExtensStorageIDB") which is read by the extension when is starting and stored in a property of the internal extension object, so that it can be used in the main and the child process to ensure that we keep using the same backend while the extension is running, even if the preference is switched. ## storage.local data migration When the new backend is enabled and the extension is started (or restarted), the data stored in the storage.local file backend is migrated into the IndexedDB backend before any extension page is loaded. ## storage.local memory usage optimization When the IDB storage.local backend is enabled, the data stored in the underlying IndexedDB storage is not in a single file anymore: - data smaller than a certain threshold is compressed and stored in the sqlite file - data bigger than the threshold is compressed and stored into separated files The above is the standard IndexedDB behavior and it should provide better performance than the simple File backend currently used by the API for certain usage scenarios of the API (e.g. there is a higher chance that we can handle the storage.local API call without loading the full file content in memory). Besides this potential improvements (which are side-effects of using IndexedDB as a backend), we can still introduce some additional optimization related to the memory usage of the API in some additional API usage scenarios: - storage.local.get API call can be fulfilled in ext-c-storage.js, so that we can spare a good amount of memory by not serialize/deseriale the API call parameters and results (as well as moving this data between processes using message manager events) - when there are no storage.onChanged listeners subscribed, we can make storage.local.set/remove/clear to use a smaller amount of memory by: - fulfill the API call in the child process if there were no onChanged listeners subscribed when the API call is starting to process the request - do not retrieve the previously stored data to be able to sent the onChanged event details (because we do not need to send any event) - when processing the API call in the main process (because there were listeners when the API call was starting to be processed in ext-c-storage.js), we can still try to spare some memory in ext-storage.js if all the onChanged listeners have been unsubscribed in the meantime (by not retrieving the previously stored data and not creating the onChanged event details) MozReview-Commit-ID: CHbkoqBsOQ7
modules/libpref/init/all.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/ExtensionStorageIDB.jsm
toolkit/components/extensions/ext-c-storage.js
toolkit/components/extensions/ext-storage.js
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -4966,16 +4966,18 @@ pref("extensions.webextensions.identity.
 // Whether or not webextension themes are supported.
 pref("extensions.webextensions.themes.enabled", false);
 pref("extensions.webextensions.themes.icons.enabled", false);
 pref("extensions.webextensions.remote", false);
 // Whether or not the moz-extension resource loads are remoted. For debugging
 // purposes only. Setting this to false will break moz-extension URI loading
 // unless other process sandboxing and extension remoting prefs are changed.
 pref("extensions.webextensions.protocol.remote", true);
+// whether or not the storage.local API should use the IndexedDB backend.
+pref("extensions.webextensions.useExtensionStorageIDB", false);
 
 // Report Site Issue button
 pref("extensions.webcompat-reporter.newIssueEndpoint", "https://webcompat.com/issues/new");
 #if defined(MOZ_DEV_EDITION) || defined(NIGHTLY_BUILD)
 pref("extensions.webcompat-reporter.enabled", true);
 #else
 pref("extensions.webcompat-reporter.enabled", false);
 #endif
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -43,16 +43,17 @@ Cu.import("resource://gre/modules/Servic
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManager: "resource://gre/modules/AddonManager.jsm",
   AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm",
   AppConstants: "resource://gre/modules/AppConstants.jsm",
   AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
   ExtensionCommon: "resource://gre/modules/ExtensionCommon.jsm",
   ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm",
   ExtensionStorage: "resource://gre/modules/ExtensionStorage.jsm",
+  ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.jsm",
   ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.jsm",
   FileSource: "resource://gre/modules/L10nRegistry.jsm",
   L10nRegistry: "resource://gre/modules/L10nRegistry.jsm",
   Log: "resource://gre/modules/Log.jsm",
   MessageChannel: "resource://gre/modules/MessageChannel.jsm",
   NetUtil: "resource://gre/modules/NetUtil.jsm",
   OS: "resource://gre/modules/osfile.jsm",
   PluralForm: "resource://gre/modules/PluralForm.jsm",
@@ -92,16 +93,19 @@ const {
   EventEmitter,
   getUniqueId,
 } = ExtensionUtils;
 
 XPCOMUtils.defineLazyGetter(this, "console", ExtensionUtils.getConsole);
 
 XPCOMUtils.defineLazyGetter(this, "LocaleData", () => ExtensionCommon.LocaleData);
 
+const EXT_STORAGE_IDB_PREF = "extensions.webextensions.useExtensionStorageIDB";
+const EXT_STORAGE_IDB_PREF_DEFAULT = false;
+
 // The maximum time to wait for extension shutdown blockers to complete.
 const SHUTDOWN_BLOCKER_MAX_MS = 8000;
 
 // The list of properties that themes are allowed to contain.
 XPCOMUtils.defineLazyGetter(this, "allowedThemeProperties", () => {
   Cu.import("resource://gre/modules/ExtensionParent.jsm");
   let propertiesInBaseManifest = ExtensionParent.baseManifestProperties;
 
@@ -243,20 +247,27 @@ var UninstallObserver = {
   onUninstalled(addon) {
     let uuid = UUIDMap.get(addon.id, false);
     if (!uuid) {
       return;
     }
 
     if (!Services.prefs.getBoolPref(LEAVE_STORAGE_PREF, false)) {
       // Clear browser.local.storage
+      // TODO: we should skip the notification (and the related
+      // higher memory usage when clearing the file backend as
+      // we do for the IDB backend).
       AsyncShutdown.profileChangeTeardown.addBlocker(
-        `Clear Extension Storage ${addon.id}`,
+        `Clear Extension Storage ${addon.id} (File Backend)`,
         ExtensionStorage.clear(addon.id));
 
+      AsyncShutdown.profileChangeTeardown.addBlocker(
+        `Clear Extension Storage ${addon.id} (IDB Backend)`,
+        ExtensionStorageIDB.clear(addon.id, {skipNotify: true}));
+
       // Clear any IndexedDB storage created by the extension
       let baseURI = Services.io.newURI(`moz-extension://${uuid}/`);
       let principal = Services.scriptSecurityManager.createCodebasePrincipal(
         baseURI, {});
       Services.qms.clearStoragesForPrincipal(principal);
 
       // Clear localStorage created by the extension
       let storage = Services.domStorageManager.getStorage(null, principal);
@@ -1276,16 +1287,23 @@ this.Extension = class extends Extension
       contentScripts: this.contentScripts,
       registeredContentScripts: new Map(),
       webAccessibleResources: this.webAccessibleResources.map(res => res.glob),
       whiteListedHosts: this.whiteListedHosts.patterns.map(pat => pat.pattern),
       localeData: this.localeData.serialize(),
       permissions: this.permissions,
       principal: this.principal,
       optionalPermissions: this.manifest.optional_permissions,
+
+      // Used to preserve the storage.local backend that was active
+      // when the extension has been started for the entire life of the addon
+      // (may change if the addon has been restarted after the preference has been
+      // switched, and the existent storage.local data can be migrated in the new
+      // IndexedDB backend when the addon is restarting).
+      useExtensionStorageIDB: this.useExtensionStorageIDB,
     };
   }
 
   get contentScripts() {
     return this.manifest.content_scripts || [];
   }
 
   broadcast(msg, data) {
@@ -1471,16 +1489,23 @@ this.Extension = class extends Extension
 
       GlobalManager.init(this);
 
       this.policy.active = false;
       this.policy = processScript.initExtension(this);
 
       this.updatePermissions(this.startupReason);
 
+      // Before starting the extension, check if the indexedDB backend has been enabled,
+      // and ensure that any storage.local data stored in the file backend has been
+      // migrated to the indexedDB backend.
+      if (this.useExtensionStorageIDB) {
+        await this.migrateStorageLocalData();
+      }
+
       // The "startup" Management event sent on the extension instance itself
       // is emitted just before the Management "startup" event,
       // and it is used to run code that needs to be executed before
       // any of the "startup" listeners.
       this.emit("startup", this);
       Management.emit("startup", this);
 
       await this.runManifest(this.manifest);
@@ -1499,16 +1524,73 @@ this.Extension = class extends Extension
       this.cleanupGeneratedFile();
 
       throw e;
     }
 
     this.startupPromise = null;
   }
 
+  async migrateStorageLocalData() {
+    if (!this.hasPermission("storage")) {
+      return;
+    }
+
+    let oldStorageFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+    oldStorageFile.initWithPath(ExtensionStorage.getStorageFile(this.id));
+
+    if (!oldStorageFile.exists()) {
+      return;
+    }
+
+    const isEmpty = await ExtensionStorageIDB.isEmpty(this.id);
+
+    if (!isEmpty) {
+      // Cancel the data migration is the indexedDB backend for the extension is not empty.
+      return;
+    }
+
+    let jsonFile;
+
+    try {
+      Services.console.logStringMessage(`Migrating storage.local data ${this.id}...`);
+
+      jsonFile = await ExtensionStorage.getFile(this.id);
+    } catch (err) {
+      Cu.reportError(
+        new Error(`Extension error during storage.local data migration for ${this.id}: ` +
+                  `${err.message} :: ${err.stack}`)
+      );
+
+      return;
+    }
+
+    let completedWithErrors = false;
+
+    for (let [key, value] of jsonFile.data.entries()) {
+      try {
+        await ExtensionStorageIDB.set(this.id, {[key]: value});
+      } catch (err) {
+        completedWithErrors = true;
+
+        // Report the migration error and continue to migrate the remaining data.
+        Cu.reportError(
+          new Error(`Extension error during storage.local data migration for ${this.id} ` +
+                    `on key '${key}': ${err.message} :: ${err.stack}`)
+        );
+      }
+    }
+
+    oldStorageFile.renameTo(null, `${oldStorageFile.leafName}.migrated`);
+
+    const msg = completedWithErrors ? "with errors" : "successfully";
+
+    Services.console.logStringMessage(`Migrating storage.local data ${this.id} completed ${msg}.`);
+  }
+
   cleanupGeneratedFile() {
     if (!this.cleanupFile) {
       return;
     }
 
     let file = this.cleanupFile;
     this.cleanupFile = null;
 
@@ -1646,16 +1728,25 @@ this.Extension = class extends Extension
 
   get optionalOrigins() {
     if (this._optionalOrigins == null) {
       let origins = this.manifest.optional_permissions.filter(perm => classifyPermission(perm).origin);
       this._optionalOrigins = new MatchPatternSet(origins, {ignorePath: true});
     }
     return this._optionalOrigins;
   }
+
+  get useExtensionStorageIDB() {
+    if (this._useExtensionStorageIDB == null) {
+      this._useExtensionStorageIDB = Services.prefs.getBoolPref(EXT_STORAGE_IDB_PREF,
+                                                                EXT_STORAGE_IDB_PREF_DEFAULT);
+    }
+
+    return this._useExtensionStorageIDB;
+  }
 };
 
 this.Langpack = class extends ExtensionData {
   constructor(addonData, startupReason) {
     super(addonData.resourceURI);
     this.startupData = addonData.startupData;
     this.manifestCacheKey = [addonData.id, addonData.version];
   }
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -573,16 +573,20 @@ class BrowserExtensionContent extends Ev
     this.principal = data.principal;
 
     this.localeData = new LocaleData(data.localeData);
 
     this.manifest = data.manifest;
     this.baseURL = data.baseURL;
     this.baseURI = Services.io.newURI(data.baseURL);
 
+    // Preserve the same storage.local backend choosen by the extension
+    // in the main process.
+    this.useExtensionStorageIDB = data.useExtensionStorageIDB;
+
     // Only used in addon processes.
     this.views = new Set();
 
     // Only used for devtools views.
     this.devtoolsViews = new Set();
 
     /* eslint-disable mozilla/balanced-listeners */
     this.on("add-permissions", (ignoreEvent, permissions) => {
--- a/toolkit/components/extensions/ExtensionStorageIDB.jsm
+++ b/toolkit/components/extensions/ExtensionStorageIDB.jsm
@@ -57,47 +57,73 @@ this.ExtensionStorageIDB = {
       }
     }
 
     await Promise.all(loadedPromises);
 
     return result;
   },
 
+  async isEmpty(extensionId) {
+    const db = await this._open(extensionId);
+    const storedKeys = await this._loadAllStoredKeys(db);
+    await db.close();
+    return storedKeys.length === 0;
+  },
+
   /**
    * Asynchronously sets the values of the given storage items for the
    * given extension.
    *
    * @param {string} extensionId
    *        The ID of the extension for which to set storage values.
    * @param {object} items
    *        The storage items to set. For each property in the object,
    *        the storage value for that property is set to its value in
    *        said object. Any values which are StructuredCloneHolder
    *        instances are deserialized before being stored.
+   * @param {object}  options
+   * @param {boolean} options.skipNotify
+   *        Set to true to skip the onChanged event notification
+   *        (and use a lower amount of memory).
    * @returns {Promise<void>}
    */
-  async set(extensionId, items) {
+  async set(extensionId, items, {skipNotify} = {}) {
     const db = await this._open(extensionId);
-    const data = await this._loadData(db, Object.keys(items));
+    let data;
+
+    if (!skipNotify) {
+      data = await this._loadData(db, Object.keys(items));
+    }
 
     const changes = {};
     const savedPromises = [];
 
     const store = db.objectStore(IDB_DATA_STORENAME, "readwrite");
 
     for (let key in items) {
       let value = items[key];
-      changes[key] = {oldValue: data[key], newValue: value};
+
+      if (!skipNotify) {
+        changes[key] = {
+          oldValue: data[key],
+          newValue: value,
+        };
+      }
+
       savedPromises.push(store.put(value, key));
     }
 
     await Promise.all(savedPromises);
 
-    this.notifyListeners(extensionId, changes);
+    await db.close();
+
+    if (!skipNotify) {
+      this.notifyListeners(extensionId, changes);
+    }
 
     return null;
   },
 
   /**
    * Asynchronously retrieves the values for the given storage items for
    * the given extension ID.
    *
@@ -126,16 +152,18 @@ this.ExtensionStorageIDB = {
       keys = Object.keys(keysParam);
     }
 
     const db = await this._open(extensionId);
 
     // Load the required data (everything if keysParam is null).
     const data = await this._loadData(db, keys);
 
+    await db.close();
+
     // If keysParam is an object, use the passed value for every non existent key.
     if (hasDefaults) {
       for (let key of keys) {
         if (!(key in data)) {
           data[key] = keysParam[key];
         }
       }
     }
@@ -146,75 +174,105 @@ this.ExtensionStorageIDB = {
   /**
    * Asynchronously removes the given storage items for the given
    * extension ID.
    *
    * @param {string} extensionId
    *        The ID of the extension for which to remove storage values.
    * @param {string|Array<string>} keys
    *        A string key of a list of storage items keys to remove.
+   * @param {object}  options
+   * @param {boolean} options.skipNotify
+   *        Set to true to skip the onChanged event notification
+   *        (and use a lower amount of memory).
    * @returns {Promise<void>}
    */
-  async remove(extensionId, keys) {
+  async remove(extensionId, keys, {skipNotify} = {}) {
     if (!keys || keys.length === 0) {
       return null;
     }
 
     // Ensure that keys is an array of strings.
     keys = [].concat(keys);
 
     const db = await this._open(extensionId);
-    const data = await this._loadData(db, keys);
+    let data;
+
+    if (!skipNotify) {
+      data = await this._loadData(db, keys);
+    }
 
     const store = db.objectStore(IDB_DATA_STORENAME, "readwrite");
 
     let changed = false;
     let changes = {};
     let promisesChanged = [];
 
     for (let key of keys) {
-      if (key in data) {
+      if (skipNotify) {
+        promisesChanged.push(store.delete(key));
+      } else if (key in data) {
         changes[key] = {oldValue: data[key]};
         promisesChanged.push(store.delete(key));
         changed = true;
       }
     }
 
-    if (changed) {
-      await promisesChanged;
+    await promisesChanged;
+
+    await db.close();
+
+    if (!skipNotify && changed) {
       this.notifyListeners(extensionId, changes);
     }
 
     return null;
   },
 
   /**
    * Asynchronously clears all storage entries for the given extension
    * ID.
    *
    * @param {string} extensionId
    *        The ID of the extension for which to clear storage.
+   * @param {object}  options
+   * @param {boolean} options.skipNotify
+   *        Set to true to skip the onChanged event notification
+   *        (and use a lower amount of memory).
    * @returns {Promise<void>}
    */
-  async clear(extensionId) {
+  async clear(extensionId, {skipNotify} = {}) {
     const db = await this._open(extensionId);
-    const data = await this._loadData(db);
+
+    let data;
+
+    if (!skipNotify) {
+      data = await this._loadData(db);
+    }
 
     let changed = false;
     let changes = {};
 
+    const store = db.objectStore(IDB_DATA_STORENAME, "readwrite");
+
+    if (skipNotify) {
+      await store.clear();
+      await db.close();
+
+      return null;
+    }
+
     for (let key in data) {
       changes[key] = {oldValue: data[key]};
       changed = true;
     }
 
     if (changed) {
-      const store = db.objectStore(IDB_DATA_STORENAME, "readwrite");
-
       await store.clear();
+      await db.close();
 
       this.notifyListeners(extensionId, changes);
     }
 
     return null;
   },
 
   // Move these into a shared ExtensionStorageBase class?
@@ -232,9 +290,14 @@ this.ExtensionStorageIDB = {
   notifyListeners(extensionId, changes) {
     let listeners = this.listeners.get(extensionId);
     if (listeners) {
       for (let listener of listeners) {
         listener(changes);
       }
     }
   },
+
+  hasListeners(extensionId) {
+    let listeners = this.listeners.get(extensionId);
+    return listeners && listeners.size > 0;
+  },
 };
--- a/toolkit/components/extensions/ext-c-storage.js
+++ b/toolkit/components/extensions/ext-c-storage.js
@@ -1,21 +1,133 @@
 "use strict";
 
 /* import-globals-from ext-c-toolkit.js */
 
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage",
                                   "resource://gre/modules/ExtensionStorage.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorageIDB",
+                                  "resource://gre/modules/ExtensionStorageIDB.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch",
                                   "resource://gre/modules/TelemetryStopwatch.jsm");
 
 const storageGetHistogram = "WEBEXT_STORAGE_LOCAL_GET_MS";
 const storageSetHistogram = "WEBEXT_STORAGE_LOCAL_SET_MS";
 
 this.storage = class extends ExtensionAPI {
+  getLocalFileBackend(context, {deserialize, serialize}) {
+    return {
+      get: async function(keys) {
+        const stopwatchKey = {};
+        TelemetryStopwatch.start(storageGetHistogram, stopwatchKey);
+        try {
+          let result = await context.childManager.callParentAsyncFunction("storage.local.get", [
+            serialize(keys),
+          ]).then(deserialize);
+          TelemetryStopwatch.finish(storageGetHistogram, stopwatchKey);
+          return result;
+        } catch (e) {
+          TelemetryStopwatch.cancel(storageGetHistogram, stopwatchKey);
+          throw e;
+        }
+      },
+      set: async function(items) {
+        const stopwatchKey = {};
+        TelemetryStopwatch.start(storageSetHistogram, stopwatchKey);
+        try {
+          let result = await context.childManager.callParentAsyncFunction("storage.local.set", [
+            serialize(items),
+          ]);
+          TelemetryStopwatch.finish(storageSetHistogram, stopwatchKey);
+          return result;
+        } catch (e) {
+          TelemetryStopwatch.cancel(storageSetHistogram, stopwatchKey);
+          throw e;
+        }
+      },
+    };
+  }
+
+  getLocalIDBBackend(context, {hasParentListeners, serialize}) {
+    return {
+      get: async function(keys) {
+        const stopwatchKey = {};
+        TelemetryStopwatch.start(storageGetHistogram, stopwatchKey);
+        try {
+          let result = await ExtensionStorageIDB.get(context.extension.id, keys);
+
+          TelemetryStopwatch.finish(storageGetHistogram, stopwatchKey);
+          return result;
+        } catch (e) {
+          TelemetryStopwatch.cancel(storageGetHistogram, stopwatchKey);
+          throw e;
+        }
+      },
+      set: async function(items) {
+        const stopwatchKey = {};
+        TelemetryStopwatch.start(storageSetHistogram, stopwatchKey);
+        try {
+          const hasListeners = await hasParentListeners();
+
+          let result;
+
+          if (hasListeners) {
+            result = await context.childManager.callParentAsyncFunction("storage.local.set", [
+              serialize(items),
+            ]);
+          } else {
+            result = await ExtensionStorageIDB.set(context.extension.id, items, {
+              skipNotify: true,
+            });
+          }
+
+          TelemetryStopwatch.finish(storageSetHistogram, stopwatchKey);
+          return result;
+        } catch (e) {
+          TelemetryStopwatch.cancel(storageSetHistogram, stopwatchKey);
+          throw e;
+        }
+      },
+      remove: async function(keys) {
+        // TODO: we should also collect telemetry on storage.local.remove.
+        const hasListeners = await hasParentListeners();
+
+        let result;
+
+        if (hasListeners) {
+          result = await context.childManager.callParentAsyncFunction("storage.local.remove", [
+            serialize(keys),
+          ]);
+        } else {
+          result = await ExtensionStorageIDB.remove(context.extension.id, keys, {
+            skipNotify: true,
+          });
+        }
+
+        return result;
+      },
+      clear: async function() {
+        // TODO: we should also collect telemetry on storage.local.remove.
+        const hasListeners = await hasParentListeners();
+
+        let result;
+
+        if (hasListeners) {
+          result = await context.childManager.callParentAsyncFunction("storage.local.clear", []);
+        } else {
+          result = await ExtensionStorageIDB.clear(context.extension.id, {
+            skipNotify: true,
+          });
+        }
+
+        return result;
+      },
+    };
+  }
+
   getAPI(context) {
     const serialize = ExtensionStorage.serializeForContext.bind(null, context);
     const deserialize = ExtensionStorage.deserializeForContext.bind(null, context);
 
     function sanitize(items) {
       // The schema validator already takes care of arrays (which are only allowed
       // to contain strings). Strings and null are safe values.
       if (typeof items != "object" || items === null || Array.isArray(items)) {
@@ -30,48 +142,32 @@ this.storage = class extends ExtensionAP
       // So we enumerate all properties and sanitize each value individually.
       let sanitized = {};
       for (let [key, value] of Object.entries(items)) {
         sanitized[key] = ExtensionStorage.sanitize(value, context);
       }
       return sanitized;
     }
 
+    function hasParentListeners() {
+      // We spare a good amount of memory if there are no listeners around
+      // (e.g. because they have never been subscribed or they have been removed
+      // in the meantime).
+      return context.childManager.callParentAsyncFunction(
+        "storage.hasListeners", []
+      );
+    }
+
+    const localAPI = context.extension.useExtensionStorageIDB ?
+                     this.getLocalIDBBackend(context, {hasParentListeners, serialize}) :
+                     this.getLocalFileBackend(context, {deserialize, serialize});
+
     return {
       storage: {
-        local: {
-          get: async function(keys) {
-            const stopwatchKey = {};
-            TelemetryStopwatch.start(storageGetHistogram, stopwatchKey);
-            try {
-              let result = await context.childManager.callParentAsyncFunction("storage.local.get", [
-                serialize(keys),
-              ]).then(deserialize);
-              TelemetryStopwatch.finish(storageGetHistogram, stopwatchKey);
-              return result;
-            } catch (e) {
-              TelemetryStopwatch.cancel(storageGetHistogram, stopwatchKey);
-              throw e;
-            }
-          },
-          set: async function(items) {
-            const stopwatchKey = {};
-            TelemetryStopwatch.start(storageSetHistogram, stopwatchKey);
-            try {
-              let result = await context.childManager.callParentAsyncFunction("storage.local.set", [
-                serialize(items),
-              ]);
-              TelemetryStopwatch.finish(storageSetHistogram, stopwatchKey);
-              return result;
-            } catch (e) {
-              TelemetryStopwatch.cancel(storageSetHistogram, stopwatchKey);
-              throw e;
-            }
-          },
-        },
+        local: localAPI,
 
         sync: {
           get: function(keys) {
             keys = sanitize(keys);
             return context.childManager.callParentAsyncFunction("storage.sync.get", [
               keys,
             ]);
           },
--- a/toolkit/components/extensions/ext-storage.js
+++ b/toolkit/components/extensions/ext-storage.js
@@ -1,16 +1,18 @@
 "use strict";
 
 // The ext-* files are imported into the same scopes.
 /* import-globals-from ext-toolkit.js */
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm",
+  AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
   ExtensionStorage: "resource://gre/modules/ExtensionStorage.jsm",
+  ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.jsm",
   extensionStorageSync: "resource://gre/modules/ExtensionStorageSync.jsm",
   NativeManifests: "resource://gre/modules/NativeManifests.jsm",
 });
 
 var {
   ExtensionError,
 } = ExtensionUtils;
 
@@ -31,34 +33,104 @@ const lookupManagedStorage = async (exte
   let info = await NativeManifests.lookupManifest("storage", extensionId, context);
   if (info) {
     return ExtensionStorage._serializableMap(info.manifest.data);
   }
   return null;
 };
 
 this.storage = class extends ExtensionAPI {
+  getLocalFileBackend(context) {
+    const {extension} = context;
+
+    return {
+      get: function(spec) {
+        return ExtensionStorage.get(extension.id, spec);
+      },
+      set: function(items) {
+        return ExtensionStorage.set(extension.id, items);
+      },
+      remove: function(keys) {
+        return ExtensionStorage.remove(extension.id, keys);
+      },
+      clear: function() {
+        return ExtensionStorage.clear(extension.id);
+      },
+    };
+  }
+
+  getLocalIDBBackend(context, {deserialize, hasListeners}) {
+    const {extension} = context;
+
+    return {
+      set: function(items) {
+        const setDataPromise = ExtensionStorageIDB.set(extension.id, deserialize(items), {
+          // We can still spare a good amount of memory if there are no listeners around
+          // (e.g. because they have been removed in the meantime).
+          skipNotify: !hasListeners(),
+        });
+
+        AsyncShutdown.profileBeforeChange.addBlocker(
+          `Extension Storage IDB: Wait for extension ${extension.id} to save data`,
+          setDataPromise);
+
+        return setDataPromise;
+      },
+      remove: function(keys) {
+        const removeDataPromise = ExtensionStorageIDB.remove(extension.id, keys, {
+          // We can still spare a good amount of memory if there are no listeners around
+          // (e.g. because they have been removed in the meantime).
+          skipNotify: !hasListeners(),
+        });
+
+        AsyncShutdown.profileBeforeChange.addBlocker(
+          `Extension Storage IDB: Wait for extension ${extension.id} to save data`,
+          removeDataPromise);
+
+        return removeDataPromise;
+      },
+      clear: function() {
+        const clearDataPromise = ExtensionStorageIDB.clear(extension.id, {
+          // We can still spare a good amount of memory if there are no listeners around
+          // (e.g. because they have been removed in the meantime).
+          skipNotify: !hasListeners(),
+        });
+
+        AsyncShutdown.profileBeforeChange.addBlocker(
+          `Extension Storage IDB: Wait for extension ${extension.id} to save data`,
+          clearDataPromise);
+
+        return clearDataPromise;
+      },
+    };
+  }
+
   getAPI(context) {
     let {extension} = context;
+
+    // A StructuredCloneHolder object cannot be stored in IndexedDB like it is,
+    // we need to deserialize it.
+    const deserialize = ExtensionStorage.deserializeForContext.bind(null, context);
+
+    function hasListeners() {
+      return ExtensionStorageIDB.hasListeners(context.extension.id);
+    }
+
+    const localAPI = extension.useExtensionStorageIDB ?
+                     this.getLocalIDBBackend(context, {deserialize, hasListeners}) :
+                     this.getLocalFileBackend(context);
+
     return {
       storage: {
-        local: {
-          get: function(spec) {
-            return ExtensionStorage.get(extension.id, spec);
-          },
-          set: function(items) {
-            return ExtensionStorage.set(extension.id, items);
-          },
-          remove: function(keys) {
-            return ExtensionStorage.remove(extension.id, keys);
-          },
-          clear: function() {
-            return ExtensionStorage.clear(extension.id);
-          },
-        },
+        // Used in ext-c-storage.js, if there are no onChanged listeners subscribed
+        // there will no need to exchange the data between the child and main process
+        // (which will help to use less memory).
+        hasListeners,
+
+        local: localAPI,
 
         sync: {
           get: function(spec) {
             enforceNoTemporaryAddon(extension.id);
             return extensionStorageSync.get(extension, spec, context);
           },
           set: function(items) {
             enforceNoTemporaryAddon(extension.id);
@@ -95,19 +167,27 @@ this.storage = class extends ExtensionAP
         onChanged: new EventManager(context, "storage.onChanged", fire => {
           let listenerLocal = changes => {
             fire.raw(changes, "local");
           };
           let listenerSync = changes => {
             fire.async(changes, "sync");
           };
 
-          ExtensionStorage.addOnChangedListener(extension.id, listenerLocal);
+          if (context.extension.useExtensionStorageIDB) {
+            ExtensionStorageIDB.addOnChangedListener(extension.id, listenerLocal);
+          } else {
+            ExtensionStorage.addOnChangedListener(extension.id, listenerLocal);
+          }
           extensionStorageSync.addOnChangedListener(extension, listenerSync, context);
           return () => {
-            ExtensionStorage.removeOnChangedListener(extension.id, listenerLocal);
+            if (context.extension.useExtensionStorageIDB) {
+              ExtensionStorageIDB.removeOnChangedListener(extension.id, listenerLocal);
+            } else {
+              ExtensionStorage.removeOnChangedListener(extension.id, listenerLocal);
+            }
             extensionStorageSync.removeOnChangedListener(extension, listenerSync);
           };
         }).api(),
       },
     };
   }
 };