Using JavaScript to fill localStorage to its maximum capacity

#javascript, #webdev
Jun 26, 2022

Earlier this week, I had to test how one web app I work on behaves when it tries to store some data in an already full localStorage.

To do so, I wanted to find a way to programmatically fill localStorage to its maximum capacity using JavaScript.
Knowing that the only way to detect when the localStorage is full is catching the QuotaExceededError when invoking localStorage.setItem), my initial “MVP” idea was to write a loop that increased the localStorage size byte by byte until it was full, such as:

try {
  for (let i = 1; i < Infinity; i++) {
    localStorage.setItem("__test__", "x".repeat(i));
  }
} catch (err) {}

Writing byte by byte is not a practical option, though. It’s very inefficient and can easily crash the browser.

So, inspired by this StackOverflow answer, I opted for storing bigger strings first and then fallback to smaller strings until localStorage is full:

/**
 * Fill localStorage to its maximum capacity.
 *
 * First, we fill localStorage in chunks of 100.000 characters until it
 * hits the exceeded quota exception.
 * Then, we do it again with chunks of 1.000 characters.
 * Finally, we do it again character by character, to ensure localStorage is
 * completely full.
 *
 * To cleanup localStorage you can use localStorage.clear(). Still, just in case
 * you wanted to clean up only the data stored by this function (maybe because
 * you want to keep in the localStorage the stuff you stored before running it),
 * we return a convenient cleanup function.
 *
 * @return A cleanup function to delete the data we stored.
 */
function fillLocalStorage(): () => void {
  function storeIncreasinglyBigItem(
    key: string,
    charactersIncrement: number
  ): void {
    const MAX_ITERATIONS = 10_000; // Safeguard against OOM & crashes
    for (let i = 1; i <= MAX_ITERATIONS; i++) {
      localStorage.setItem(key, "x".repeat(i * charactersIncrement));
    }
  }
  try {
    storeIncreasinglyBigItem("__1", 100_000);
  } catch (_err1) {
    try {
      storeIncreasinglyBigItem("__2", 1_000);
    } catch (_err2) {
      try {
        storeIncreasinglyBigItem("__3", 1);
      } catch (_err3) {
        // Storage is now completely full 🍟
      }
    }
  }
  return function cleanup() {
    localStorage.removeItem("__1");
    localStorage.removeItem("__2");
    localStorage.removeItem("__3");
  };
}

Please notice that this solution has a tiny edge-case where fillLocalStorage doesn’t 100% fill the localStorage: since browsers include both the values and the keys to determining the localStorage size, in some extremely rare occasions we might report the storage as full when it might still have ~3 bytes left.

The snippet above could definitley be optimized, but works pretty well — and that’s what I initially suggested in this post… until I shared it on Reddit, where /u/kyle1320 and /u/palparepa suggested an even more elegant approach: finding the highest order bit first, and then testing each bit in decreasing order until localStorage is full.
This approach is more performant than the one I suggested above, because it involves less allocations and iterations, it uses a single localStorage item, and also solves the edge-case I just mentioned.

/**
 * Fill localStorage to its maximum capacity.
 *
 * First, we find the highest order bit (the bit with the highest place value)
 * that fits in localStorage by storing an increasingly big string
 * of length 2, 4, 8, 16, etc. until it won't fill localStorage anymore.
 *
 * Then, we fill the remaining space by increasing the string length
 * in the opposite order.
 *
 * By working in iterations, starting with very long strings, and storing data
 * in different items, we can keep a low memory profile and reduce the number of
 * writes — making this process pretty fast.
 *
 * To cleanup localStorage you can use localStorage.clear(). Still, just in case
 * you wanted to clean up only the data stored by this function (maybe because
 * you want to keep in the localStorage the stuff you stored before running it),
 * we return a convenient cleanup function.
 *
 * @return A cleanup function to delete the data we stored.
 */
function fillLocalStorage(): () => void {
  const key = "__filling_localstorage__";

  let max = 1; // This holds the highest order bit.
  let data = "x"; // The string we're going to store in localStorage.

  // Find the highest order bit.
  try {
    while (true) {
      data = data + data;
      localStorage.setItem(key, data);
      max <<= 1;
    }
  } catch {}

  // Fill the rest of the space by increasing the string length in the opposite
  // order.
  for (let bit = max >> 1; bit > 0; bit >>= 1) {
    try {
      localStorage.setItem(key, data.substring(0, max | bit));
      max |= bit;
    } catch {
      // Storage is now completely full 🍟
    }
  }

  // Cleanup
  return function cleanup() {
    localStorage.removeItem(key);
  };
}

Example usage:

// Fill localStorage.
const cleanupLocalStorage = fillLocalStorage();
// The localStorage is full now. Do whatever you need to.
doSomething();
// Finally, delete the dummy data we used to fill localStorage.
cleanupLocalStorage();

Example usage in Puppeteer or Playwright:

test.afterEach(async ({ page }) => {
  await page.evaluate(() => window.localStorage.clear());
});

test("Do something when localStorage is full", async ({ page }) => {
  // Fill localStorage.
  // Notice that the page is running in a different JavaScript context, so
  // we serialize the "fillLocalStorage" utility (it's OK, it's a pure function)
  // and run an eval on the browser side.
  await page.evaluate(`(${fillLocalStorage.toString()})()`);

  // The localStorage is full now. Do whatever you need to.
  doSomething();
});