📌

LocalStorageはパフォーマンスにどれほど悪影響か?実測して確かめる

2024/04/05に公開

概要

localStorageのset/get/removeのパフォーマンスを計測。大量のデータを扱うと確かに遅いが、通常の利用範囲内であれば、パフォーマンス上の懸念は薄いと考えられる。

背景

localStorage使用の懸念点として、「localStorageは同期的に読み書きされるため、パフォーマンス影響が出やすい」旨を聞くことがあります。
たとえば、下記記事でもlocalStorageの短所の一つとして述べられています。
https://techracho.bpsinc.jp/hachi8833/2019_10_09/80851

これに対し、実際のlocalStorageのパフォーマンスを実測した記事(2019年以降のもの)はいくつか見つかります。下記2つの記事では、localStorageが同期的な処理である問題はありつつも、処理自体は比較的高速であると結論しています。
https://zenn.dev/kthatoto/articles/ccdb4387b42ba1
https://gomakethings.com/how-fast-is-vanilla-js-localstorage/

しかし、上記記事では、下記のような計測の問題点もあります。

  • get/setのそれぞれの操作にかかる時間が分離されていない。(実際使う時はget単体での操作頻度のほうが高いと思います)
  • データのJSON.stringify/parseの処理時間が入っている(これは実際のアプリで使うときには多くの場合必要な処理ではありますが、純粋なlocalStorageのパフォーマンスとは関係ありません)
  • 1 keyあたりのデータ量と、keyの総数それぞれに対するスケーリングが計測されていない

計測方針

この記事では、上記をカバーする計測を下記観点で行いました。

  • 少量のデータ(16byte)を持つ大量(<10000個)のkeyをset/get/removeしたらどうなるか?
  • 大量のデータ(<10MB)を持つ1つのkeyをset/get/removeしたらどうなるか?
  • 大量のデータ(=2MB)を持つkeyがlocalStorageに存在するとき、それとは別のkeyをset/get/removeしたらどうなるか?(=他のkeyが操作に影響を与えることがあるか?)

計測環境は下記です。

  • MacBook Air (2020)
  • CPU: Apple M1
  • RAM: 16 GB
  • macOS 14.3
  • Browser: Chrome 123.0.6312.87 (シークレットタブを利用)

なお、計測に使ったコードはこちらにあげています。

1. 少量のデータを持つ大量のkeyをset/get/removeする

ランダムな8文字の英数字文字列をkey、そのkeyと同じ文字列をvalueとして持つようなデータを、最大10000回それぞれset/get/removeしたときに何ミリ秒かかるかを計測しました。
dev toolsのパフォーマンスタブを用いてCPU 4x slowdownおよびCPU 6x slowdownでも計測しています。

計測に使ったコード
<html>
  <head>
    <title>Large Number of Keys</title>
  </head>
  <body>
    <button id="exec">exec test</button>
    <script>
      const NUMBER_OF_KEYS = [1, 2500, 5000, 7500, 10000];
      const TEST_TRIALS = 5;

      const execTest = () => {
        const results = [];

        for (let i = 0; i < NUMBER_OF_KEYS.length; i++) {
          const n = NUMBER_OF_KEYS[i];
          const keys = [...Array(n)].map(() =>
            Math.random().toString(36).slice(-8)
          );

          const result = [];

          const offsetStart = performance.now();
          for (const key of keys) {
          }
          const offsetDuration = performance.now() - offsetStart;

          const set = () => {
            for (const key of keys) {
              localStorage.setItem(key, key);
            }
          };
          const setStart = performance.now();
          set();
          const setDuration = performance.now() - setStart - offsetDuration;
          console.log(`Set ${n} keys`, setDuration);
          result.push(setDuration);

          const get = () => {
            for (const key of keys) {
              localStorage.getItem(key);
            }
          };
          const getStart = performance.now();
          get();
          const getDuration = performance.now() - getStart - offsetDuration;
          console.log(`Get ${n} keys`, getDuration);
          result.push(getDuration);

          const remove = () => {
            for (const key of keys) {
              localStorage.removeItem(key);
            }
          };
          const removeStart = performance.now();
          remove();
          const removeDuration =
            performance.now() - removeStart - offsetDuration;
          console.log(`Remove ${n} keys`, removeDuration);
          result.push(removeDuration);

          results.push(result);
        }

        return results;
      };

      const execMultiTests = async () => {
        const averages = [...Array(NUMBER_OF_KEYS.length)].map(() =>
          [...Array(3)].map(() => 0)
        );
        for (let i = 0; i < TEST_TRIALS; i++) {
          const results = execTest();
          for (let j = 0; j < results.length; j++) {
            for (let k = 0; k < results[j].length; k++) {
              averages[j][k] += results[j][k] / TEST_TRIALS;
            }
          }
          await new Promise((resolve) => setTimeout(resolve, 1000));
        }

        console.log(JSON.stringify(averages));
      };

      document.getElementById("exec").addEventListener("click", execMultiTests);
    </script>
  </body>
</html>

結果は下記の通りです。(5回計測の平均)

setItem

getItem

removeItem

setとremoveが比較的重く、getが軽い結果となっています。当たり前ですが、keyの数と操作時間は概ね比例しています。また、CPUの処理能力とパフォーマンスが相関していることもわかります。
CPU 6x slowdownのケースでは、key一個あたりの操作時間はおおよそ下記です。

  • set: 0.015 ms
  • get: 0.003 ms
  • remove: 0.014 ms

一般的なブラウザのフレームレートが60 fps (=1 frameで16.6 ms)であることを考えると、数十個程度の、データの少ないlocalStorageの読み書きであればほぼ無視できるレベルといえそうです。

2. 大量のデータを持つ1つのkeyをset/get/removeする

文字数最大5000000個(=10MB[1])のランダムな英数字文字列をvalueとして持つような1つのデータをset/get/removeしたときに何ミリ秒かかるかを計測しました。
同様にCPU 4x slowdownおよびCPU 6x slowdownでも計測しています。

計測に使ったコード
<html>
  <head>
    <title>Large Data</title>
  </head>
  <body>
    <button id="exec">exec test</button>
    <script>
      const NUMBER_OF_LETTERS = [1, 2000000, 4000000, 5000000];
      const TEST_TRIALS = 5;

      const execTest = () => {
        const results = [];

        for (let i = 0; i < NUMBER_OF_LETTERS.length; i++) {
          const n = NUMBER_OF_LETTERS[i];
          const data = [...Array(n)]
            .map(() => Math.random().toString(36).slice(-1))
            .join("");

          const result = [];

          const set = () => {
            localStorage.setItem(n, data);
          };
          const setStart = performance.now();
          set();
          const setDuration = performance.now() - setStart;
          console.log(`Set ${n} letters`, setDuration);
          result.push(setDuration);

          const get = () => {
            localStorage.getItem(n);
          };
          const getStart = performance.now();
          get();
          const getDuration = performance.now() - getStart;
          console.log(`Get ${n} letters`, getDuration);
          result.push(getDuration);

          const remove = () => {
            localStorage.removeItem(n);
          };
          const removeStart = performance.now();
          remove();
          const removeDuration = performance.now() - removeStart;
          console.log(`Remove ${n} letters`, removeDuration);
          result.push(removeDuration);

          results.push(result);
        }

        return results;
      };

      const execMultiTests = async () => {
        const averages = [...Array(NUMBER_OF_LETTERS.length)].map(() =>
          [...Array(3)].map(() => 0)
        );
        for (let i = 0; i < TEST_TRIALS; i++) {
          const results = execTest();
          for (let j = 0; j < results.length; j++) {
            for (let k = 0; k < results[j].length; k++) {
              averages[j][k] += results[j][k] / TEST_TRIALS;
            }
          }
          await new Promise((resolve) => setTimeout(resolve, 1000));
        }

        console.log(JSON.stringify(averages));
      };

      document.getElementById("exec").addEventListener("click", execMultiTests);
    </script>
  </body>
</html>

結果は下記の通りです。(5回計測の平均)

setItem

getItem

removeItem

setとgetが比較的重く、removeが軽い結果となっています。データ量と操作時間は概ね比例しており、CPUの処理能力とパフォーマンスの相関もあります。
CPU 6x slowdownのケースでは、1 kBあたりの操作時間はおおよそ下記です。

  • set: 0.004 ms
  • get: 0.004 ms
  • remove: 0.0007 ms

こちらも、数十kB程度のlocalStorageの読み書きであればほぼ無視できるレベルといえそうです。

3. 大量のデータを持つkeyが他に存在する状況でset/get/removeする

文字数最大1000000個(=2MB)のランダムな文字列をvalueとして持つような1つのデータをあらかじめ同一オリジンのlocalStorageにセットした状態で、新しくデータ量1文字のkeyをset/get/removeしたときに何ミリ秒かかるかを計測しました。
同様にCPU 4x slowdownおよびCPU 6x slowdownでも計測しています。

計測に使ったコード
  <head>
    <title>Large Neighbor Data</title>
  </head>
  <body>
    <button id="exec">exec test</button>
    <script>
      const NUMBER_OF_NEIGHBOR_LETTERS = [1, 100, 10000, 1000000];
      const TEST_TRIALS = 5;

      const execTest = () => {
        const results = [];

        for (let i = 0; i < NUMBER_OF_NEIGHBOR_LETTERS.length; i++) {
          const n = NUMBER_OF_NEIGHBOR_LETTERS[i];
          const neighborData = [...Array(n)]
            .map(() => Math.random().toString(36).slice(-1))
            .join("");
          localStorage.setItem(`${n}:neighbor`, neighborData);

          const data = "1";

          const result = [];

          const set = () => {
            localStorage.setItem(n, data);
          };
          const setStart = performance.now();
          set();
          const setDuration = performance.now() - setStart;
          console.log(`Set ${n} letters`, setDuration);
          result.push(setDuration);

          const get = () => {
            localStorage.getItem(n);
          };
          const getStart = performance.now();
          get();
          const getDuration = performance.now() - getStart;
          console.log(`Get ${n} letters`, getDuration);
          result.push(getDuration);

          const remove = () => {
            localStorage.removeItem(n);
          };
          const removeStart = performance.now();
          remove();
          const removeDuration = performance.now() - removeStart;
          console.log(`Remove ${n} letters`, removeDuration);
          result.push(removeDuration);

          results.push(result);

          localStorage.removeItem(`${n}:neighbor`);
        }

        return results;
      };

      const execMultiTests = async () => {
        const averages = [...Array(NUMBER_OF_NEIGHBOR_LETTERS.length)].map(() =>
          [...Array(3)].map(() => 0)
        );
        for (let i = 0; i < TEST_TRIALS; i++) {
          const results = execTest();
          for (let j = 0; j < results.length; j++) {
            for (let k = 0; k < results[j].length; k++) {
              averages[j][k] += results[j][k] / TEST_TRIALS;
            }
          }
          await new Promise((resolve) => setTimeout(resolve, 1000));
        }

        console.log(JSON.stringify(averages));
      };

      document.getElementById("exec").addEventListener("click", execMultiTests);
    </script>
  </body>
</html>

結果に関しては、グラフは割愛しますが、あらかじめセットしておいたデータの量によらず、set/get/removeの各操作の所要時間は0.02 ms以下でした。
同一オリジンに重たいlocalStorageのkeyがあっても、そのほかのkeyの操作には影響はほぼないと言えそうです。

まとめ

以上3つの計測をまとめると、極端にkey数やデータ量が多いlocalStorage操作は数十~数百ms程度の処理時間がかかり、ブラウザの他の処理を止めてしまうため顕著に悪影響があると言えそうですが、少量の利用範囲であれば基本的に高速だと思って良さそうです。(ただし、今回は確かめられていませんが、端末・ブラウザによる依存はあるとは思います)

脚注
  1. localStorageのデータがUTF-16で保存されることから計算しています。なお、この計測環境では、データサイズがおよそ10.5 MBを超えるとlocalStorageの容量オーバーになり、setItemができなくなりました。 ↩︎

Discussion