ブラウザのメインスレッド占有を体感で理解する ― Web Worker 導入の実験と実装パターン

はじめに

NTT西日本の中川です。
本記事では、ブラウザのメインスレッドを占有しないようWeb Workerを使って重い処理をバックグラウンドへ逃がす方法を、実験コードつきで解説します。シングルスレッドのJavaScriptでも、UXを落とさずに計算処理と描画を両立するための実装パターンをまとめました。
本記事は2026年3月時点の情報に基づきます。

仕様書やMDNなどを読んでも、「Workerに逃がす」と口で言うのと、画面上で動きの差を一度見るのとでは、腹落ちの深さがかなり違います。私自身も最初の頃はイベントループの説明と体感がなかなか結びつかず、実験用の短いforを回して初めて「占有」の意味が自分のものになった経験があります。同じように手を動かしながら確認したい方に向け、デモを多めに載せていますので、ぜひ試してみてください。

設計思想やデバッグ手法など、コードの品質を高める方法はさまざまです。しかし、どれほどきれいなコードを書いても、ユーザー体験(UX)を損なう「画面のフリーズ」が発生してしまうと、アプリケーションの価値は大きく損なわれてしまいます。
JavaScriptはメインスレッド上でUIとスクリプトが順番に処理されるため、重い処理をそのまま走らせると描画が止まります。今回は、その限界を緩和する標準機能であるWeb Workerに焦点を当ててご紹介します。

対象読者

本記事が想定する対象読者は以下の通りです。

  • JavaScriptのイベントループや実行コンテキストなど基礎知識をお持ちの方
  • 大量データの加工や複雑な計算で、画面が一瞬カクついたり止まったりする課題を抱えている方
  • 「JavaScriptはシングルスレッドだから重い処理は避けるべき」という前提の先にある、並行に近い実行モデルを知りたい方

目次

1.背景・目的

モダンなWebアプリケーションでは、ブラウザ上で実行されるロジックが肥大化し続けています。一方で、ユーザーは滑らかなアニメーションと即座のレスポンスを期待しているケースが多いです。

本記事の目的は、メインスレッドをUI応答に使い続けつつ、重い計算をWorker側へ委ねるという構成を、実際にブラウザで動かしながら体感し、実装の型を身につけていただくことです。

業務のコードでも、いったんメインに載せた処理を抱え込んだまま進め、計測で長いタスクがはっきり見えてからWorkerへ切り出す、という順番になりがちです。後から直せばよいのですが、手戻りが発生するとどんどん期間的な余裕がなくなっていくケースが多いと思います。だからこそ設計の早い段階で「メインはUIと短い応答に寄せる」という前提を共有しておくと楽になる場面が、私がこれまで経験してきた案件でも少なくありませんでした。本記事は、その前提づくりのための土台として整理しました。

2.スレッドとは

OSやCPUの厳密な定義に踏み込むと長くなるため、この記事を読むための最小限のイメージだけを解説します。
簡易な説明となりますので、あくまでイメージと捉えてください。

  • スレッド(thread) 処理の流れを運ぶ作業ラインのようなものです。レストランでいえば、調理や提供を進めるための「通路」に近いイメージです。

  • シングルスレッド 同時に使える通路が1本だけで、そこに仕事が順番に並ぶ状態です。JavaScriptのメインスレッドは、基本この「1本の通路」上で動きます。

  • メインスレッド ブラウザでは、この1本の通路のうち、画面の表示やユーザー操作(クリック・入力など)への反応を扱う主役のラインを指すことが多いです。本文では「メイン」と略します。

  • マルチスレッド(複数スレッド) 複数の通路を並行して使えるイメージです。Web Workerは、メインとは別のスレッド上でスクリプトを実行できます。ただしWorkerからはDOM(Document Object Model)を直接操作できないなど、制約があります。

この記事では「重い計算をメインの通路にずっと置くと、画面の更新が後回しになる。だからWorkerという別の通路に逃がす」というイメージを持って読み進めていただくと、後の内容を理解する助けになります。

※用語の厳密な定義や、ブラウザ実装ごとの差異は、参考資料のMDNや仕様書で確認してください。

3.なぜ「画面が止まる」のか(イベントループの制約)

JavaScriptの実行環境(メインスレッド)は、UIの描画更新とスクリプトの実行を、ひとつのキューに乗ったタスクとして順番に処理するモデルが基本です(イベントループ)。

メインスレッドとイベントループの参考イメージ(タスクキューから実行・重い処理時のブロック)
※図内の「rAF」とはrequestAnimationFrame()の略です。

上段は「順番待ちのモデル」、下段は「重い1件の間、ほかの仕事が先に進みにくい」ことをイメージした図です(厳密なブラウザ内部モデルではなく、説明用の略図です)。

例えば、数秒かかるループ処理をメインスレッドで実行すると、その間は「次の描画」に回る前に長いタスク占有が発生します。結果として、アニメーションやクリック応答が止まったように見えます。これがいわゆるメインスレッドのブロックです。

実験:メインスレッドをブロックしてみる

以下をindex.htmlとして保存し、ローカルで開いて試してください(file://ではなく簡易HTTPサーバーで配信する方が挙動が安定します)。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>メインスレッドをブロックする実験</title>
  </head>
  <body>
    <h2>メインスレッドをブロックする実験</h2>
    <p id="status">待機中です。ボタンで重い処理を開始します。</p>
    <pre
      id="log"
      style="background:#f5f5f5;padding:8px;border-radius:4px;font-size:13px;"
    ></pre>
    <div
      id="box"
      style="width:50px; height:50px; background:red; position:relative;"
    ></div>
    <div>
      <small>
        このボタンでは、わざと「重い処理」を走らせて、画面(メインスレッド)が止まる感じを体感します。<br />
        走る計算は2つです。<br />
        <br />
        1) <b>累積和(計算結果の表示)</b><br />
        0〜n-1 の合計(累積和)を表示します。<br />
        <br />
        2) <b>負荷をかけるループ(フリーズ体感用)</b><br />
        こちらはn回ループしてCPUを使い、メインスレッドを占有させるための処理です。<br />
        <code>dummy</code>
        はループ中に更新する作業用の変数で、値そのものに意味はありません。<br />
        なお内部的には32bit整数として更新しているため、表示は符号なし(0〜4,294,967,295)に変換しています。<br />
        <br />
      </small>
    </div>
    <button id="runBtn" type="button">重い処理を実行(フリーズします)</button>
    <script>
      const statusEl = document.getElementById("status");
      const logEl = document.getElementById("log");
      const runBtn = document.getElementById("runBtn");

      let pos = 0;
      function animate() {
        pos = (pos + 2) % 300;
        document.getElementById("box").style.left = pos + "px";
        requestAnimationFrame(animate);
      }
      animate();

      function fmtTime(d) {
        return (
          d.toLocaleTimeString("ja-JP", { hour12: false }) +
          "." +
          String(d.getMilliseconds()).padStart(3, "0")
        );
      }

      function heavyTask() {
        logEl.textContent = "";
        runBtn.disabled = true;
        statusEl.textContent = "次の描画フレームのあと、重い処理を開始します…";

        requestAnimationFrame(() => {
          requestAnimationFrame(() => {
            const wallStart = new Date();
            statusEl.textContent =
              "実行中です(メインスレッド占有中)。赤いボックスの動きと、この文が更新されない時間に注目してください。";
            const t0 = performance.now();
            const n = 1_000_000_000;
            let dummy = 0;
            for (let i = 0; i < n; i++) {
              dummy = (dummy + (i & 7)) | 0;
            }
            const dummyU32 = dummy >>> 0;
            const result = (BigInt(n - 1) * BigInt(n)) / 2n;
            const elapsed = Math.round(performance.now() - t0);
            const wallEnd = new Date();

            statusEl.textContent =
              "重い処理が終わりました。メインスレッドが解放され、ボックスが再び動き出します。";
            logEl.textContent =
              "【画面ログ】\n" +
              "開始時刻: " +
              fmtTime(wallStart) +
              "\n" +
              "終了時刻: " +
              fmtTime(wallEnd) +
              "\n" +
              "所要時間: " +
              elapsed +
              " ms\n" +
              "累積和 : " +
              result.toString() +
              "\n" +
              "ダミー値(符号なし32bit): " +
              dummyU32;
            runBtn.disabled = false;
          });
        });
      }

      runBtn.addEventListener("click", heavyTask);
    </script>
  </body>
</html>

結果

メインスレッドをブロックしてみるの参考イメージ

ボタンを押すと、赤いボックスの動きが止まります。これがメインスレッドが長時間占有されている状態です。あわせて、実行中は画面上部の状態メッセージが更新されない(フリーズしている)こと、終了後にログ欄へ開始・終了時刻と所要時間がまとめて表示されることも確認できます。ループ回数や端末性能など、かかる時間は環境ごとに異なります。
所要時間に表示された時間分だけ、メインスレッドが占有されていたということになります。

4.Web Workerとは何か

Web Workerは、Webアプリケーションのメイン実行スレッドとは別のバックグラウンド上でスクリプトを動かす仕組みです(実装はブラウザ依存ですが、「メインとは別の並行の実行コンテキスト」という理解で問題ありません)。重い計算をWorkerに逃がすと、メイン側はブロックされにくくなり、ユーザーの操作や画面の再描画を順に進めやすくなります。しかし、メインとWorkerの間のメッセージ処理そのものもメインスレッドで扱うため、通信のやりすぎは別のボトルネックになる可能性があり、注意が必要です。

実装時の主なルール

  1. スクリプトはURLとして渡して起動する:別ファイルの.jsを指すのが一般的ですが、Blobから生成したURLのように、ファイルを分けずに渡す方法もあります。読み込むWorkerスクリプトは呼び出し元と同一オリジンであることが基本で、クロスオリジンで読み込む場合は追加の条件(CORSなど)が必要です。
  2. DOM操作は不可:Workerの実行環境にはwindowdocumentはなく、ページのDOMを直接触れません(仕様上、Dedicated WorkerのグローバルはWorkerGlobalScope系です)。
  3. メッセージでやり取りするpostMessagemessageイベント(またはaddEventListener('message',…))でデータを受け渡しします。
  4. 不要になったら終了する:都度Workerを新規作成する場合は、処理完了後にterminate()するか、1つのWorkerを使い回す設計にします(放置するとリソースを食い続けます)。

5.【実践】Web Workerを導入してフリーズを和らげる

先ほどの実験を、Web Workerで書き直します。同じフォルダにindex.htmlworker.jsを置いてください。

ここで表したいのは、「非常に重いCPU計算(= メインスレッドを占有しがちなループ)」をWorkerへ逃がすことで、メイン側のアニメーション(赤いボックス)が止まらない、という効果です。

この例で画面に出している数値(累積和)は、「処理が最後まで完了した」ことを確認するための目印です。
経過時間ではないことに注意してください。

Worker用のスクリプトは、HTMLに<script src="worker.js">と書いて読み込みません。メイン側のnew Worker('worker.js')のときに、ブラウザが別スレッド用としてworker.jsを取得・実行します(メイン用の<script>で読むと、Worker向けコードがメインスレッドで動いてしまい意図とずれます)。

ステップ1:worker.js

// 重いループを最後まで回し切った「完了の証拠」として値をメインへ返す。
self.onmessage = function () {
  const n = 1_000_000_000;
  // ダミー計算(CPUを使う/32bitで回し続けて“走った痕跡”を残す)
  let dummy = 0;
  for (let i = 0; i < n; i++) {
    dummy = (dummy + (i & 7)) | 0;
  }
  const dummyU32 = dummy >>> 0;
  // 結果の目印(0〜n-1 の合計を BigInt で厳密に)
  const sum = (BigInt(n - 1) * BigInt(n)) / 2n;
  self.postMessage({ n, sum: sum.toString(), dummy: dummyU32 });
};

メインに届く値: 上記のpostMessage(result)の引数が、メイン側のmessageハンドラではe.dataとして受け取れます。

ステップ2:index.html(メインスレッド側)

先ほどの実験コードのうち、ボタンを押したときの処理(重いループ部分)だけをWorker呼び出しに差し替えた例です。ボタン連打でWorkerが増殖しないよう、前回のWorkerは終了してから新しく起動しています。ログ欄に出すe.dataは、ステップ1のresultと同じ値です。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>Web Workerで計算を逃がす</title>
  </head>
  <body>
    <h2>Web Workerで計算を逃がす</h2>
    <p id="status">ボタンでWorkerに計算を依頼できます。</p>
    <pre
      id="log"
      style="background:#f5f5f5;padding:8px;border-radius:4px;font-size:13px;min-height:3em;"
    >
(結果はここに表示されます)</pre
    >
    <div>
      <small>
        このボタンでは、重い計算をWorkerに任せて、画面(メインスレッド)の描画が止まりにくくなることを体感します。<br />
        Workerが返す値は2つです。<br />
        <br />
        1) <b>累積和(計算結果の表示)</b><br />
        0〜n-1 の合計(累積和)を表示します。<br />
        <br />
        2) <b>ダミー値(符号なし32bit)</b><br />
        Worker側で負荷をかけるループの中で更新している作業用の値です(値そのものに意味はありません)。<br />
      </small>
    </div>
    <div
      id="box"
      style="width:50px; height:50px; background:red; position:relative;"
    ></div>
    <br />
    <button type="button" id="runBtn">
      重い処理をWorkerへ(描画は動き続ける想定)
    </button>

    <script>
      const statusEl = document.getElementById("status");
      const logEl = document.getElementById("log");
      const runBtn = document.getElementById("runBtn");

      let pos = 0;
      function animate() {
        pos = (pos + 2) % 300;
        document.getElementById("box").style.left = pos + "px";
        requestAnimationFrame(animate);
      }
      animate();

      let myWorker = null;

      function heavyTask() {
        logEl.textContent = "";
        runBtn.disabled = true;
        statusEl.textContent =
          "Workerへ処理を送りました。計算中は画面の指示に従い、赤いボックスの動きも見てください。";

        if (myWorker) {
          myWorker.terminate();
        }
        try {
          myWorker = new Worker("worker.js");
        } catch (err) {
          statusEl.textContent = "Workerを起動できませんでした。";
          logEl.textContent =
            "起動エラー: " +
            (err && err.message ? err.message : String(err)) +
            "\n" +
            "(worker.js のパス、http:// で開いているか、ブラウザ設定などを確認してください)";
          myWorker = null;
          runBtn.disabled = false;
          return;
        }

        myWorker.onmessage = function (e) {
          // worker.js は { sum, dummy } の形で返す想定ですが、コピペのズレ等で形が違う場合もあり得るので
          // 表示は防御的に扱います。
          const data = e.data;
          const sum =
            data && typeof data === "object" ? (data.sum ?? data.result) : data;
          const dummy =
            data && typeof data === "object" ? data.dummy : undefined;
          const endTime = new Date().toLocaleTimeString("ja-JP", {
            hour12: false,
          });
          statusEl.textContent =
            "Workerから結果を受け取りました(" + endTime + ")。";
          logEl.textContent =
            "累積和(0〜999,999,999 の合計): " +
            sum +
            "\n" +
            "ダミー値(符号なし32bit): " +
            (dummy ?? "(未取得)");
          myWorker.terminate();
          myWorker = null;
          runBtn.disabled = false;
        };

        myWorker.onerror = function (e) {
          statusEl.textContent =
            "Workerの読み込み、または実行でエラーが発生しました。";
          logEl.textContent =
            "エラー: " +
            (e && e.message ? e.message : "(詳細不明)") +
            "\n" +
            "(worker.js が存在しない、パスが違う、http:// で開いていない、同一オリジンでないなどの可能性があります)";
          myWorker.terminate();
          myWorker = null;
          runBtn.disabled = false;
        };

        myWorker.onmessageerror = function () {
          statusEl.textContent =
            "Workerとのメッセージの受け渡しに失敗しました。";
          logEl.textContent =
            "messageerror: Workerから受け取ったデータを復元できませんでした。";
          myWorker.terminate();
          myWorker = null;
          runBtn.disabled = false;
        };

        myWorker.postMessage(null);
      }

      runBtn.addEventListener("click", heavyTask);
    </script>
  </body>
</html>

結果

Web Workerを導入してフリーズを和らげるの参考イメージ

先ほどとは異なり、ボタン押下中も赤いボックスのアニメーションが動き続け、数秒後にログ欄へ累積和(0〜999,999,999の合計)が表示されます。状態メッセージで、送信中・受信済みの流れも追えます。挙動はブラウザ実装・CPU負荷・他タブの状況により変わるため、可能であれば手元で確認してください。

補足:メインとWorkerのやり取りのコスト

メインからWorkerへデータを渡す処理では、原則としてデータの複製が発生します。巨大なオブジェクトを頻繁に往復させると、通信そのものがボトルネックになることがあるため、注意してください。コピーを避ける高度な手段は本記事の後半で紹介します。

6.よくあるユースケースと簡易コード(何が起きるかをセットで理解する)

ここからは1ファイルで試せる例です。new Worker(URL.createObjectURL(blob))により、別ファイルを置かずにWorkerスクリプトを渡しています(本番ではファイル分割やバンドラ連携が一般的です)。

各コードの直後に、ブラウザ上でどう見えるか・何が得られるかをまとめています。

ファイルを増やしたくないときにBlobからWorkerを起動するのは、個人的にも手習い向きで重宝しています。本番とは設計が異なる点は後述の通りですが、「まず挙動を掴む」には向いています。

ケース1:回転する「動き続けるUI」と重い処理(ループ)

重いCPUループは本来メインを占有しやすいことはこれまでの内容でお伝えしてきましたが、ここでは回転する赤い四角を置き、「止まらない=メインの描画処理が生きている」ことを一目で確認できるように画面上の「経過時間(メイン)」は、メインが動いている間だけ増え続けるようにしています。
Worker処理完了時に出る巨大な数値(累計)は、ループを最後まで回し切った完了の目印です。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>ケース1:回転UIと重い処理(Worker)</title>
  </head>
  <body>
    <h3>回転が止まらないか、注目してみてください</h3>
    <p>赤い四角は <code>requestAnimationFrame</code> で回転しています。</p>
    <div>
      <small>
        この例は、重い計算をWorkerに任せても「メイン側の描画(回転)」が止まりにくいことを体感するためのものです。<br />
        <br />
        - 確認するポイント: <b>赤い四角の回転</b><b>経過時間</b> が止まらないか<br />
        - Workerが返す値: <b>累積和(計算結果)</b><b>ダミー値(符号なし32bit)</b><br />
        ※ダミー値は負荷をかけるループ中に更新している作業用の値で、値そのものに意味はありません。<br />
      </small>
    </div>
    <div
      id="spin"
      style="width:48px;height:48px;background:#d33;margin:12px 0;transform-origin:center center;"
    ></div>
    <p>
      経過時間(メイン):
      <span id="tick">0</span> ms(フレームが進むほど増えます)
    </p>
    <p id="st">待機中</p>
    <button type="button" id="run">Workerで重いループ(約2億ステップ)</button>
    <script>
      const spinEl = document.getElementById("spin");
      const tickEl = document.getElementById("tick");
      const st = document.getElementById("st");
      const runBtn = document.getElementById("run");

      let deg = 0;
      (function spinLoop() {
        deg = (deg + 4) % 360;
        spinEl.style.transform = "rotate(" + deg + "deg)";
        requestAnimationFrame(spinLoop);
      })();

      let t0 = performance.now();
      (function tickLoop() {
        tickEl.textContent = String(Math.round(performance.now() - t0));
        requestAnimationFrame(tickLoop);
      })();

      runBtn.onclick = () => {
        runBtn.disabled = true;
        st.textContent =
          "Workerで計算中…(この間も回転と経過表示が止まらないはず)";

        const src = [
          "self.onmessage = () => {",
          "  const n = 200000000;",
          "  let dummy = 0;",
          "  for (let i = 0; i < n; i++) dummy = (dummy + (i & 7)) | 0;",
          "  const dummyU32 = dummy >>> 0;",
          "  const sum = (BigInt(n - 1) * BigInt(n)) / 2n;",
          "  self.postMessage({ steps: n, sum: sum.toString(), dummy: dummyU32 });",
          "};",
        ].join("\n");
        const w = new Worker(
          URL.createObjectURL(
            new Blob([src], { type: "application/javascript" }),
          ),
        );
        w.onmessage = (e) => {
          st.textContent =
            "完了(Worker): ステップ数 " +
            e.data.steps +
            " の累積和 = " +
            e.data.sum +
            "(ダミー値・符号なし32bit: " +
            e.data.dummy +
            ")";
          runBtn.disabled = false;
          w.terminate();
        };
        w.onerror = (e) => {
          st.textContent = "Workerエラー: " + e.message;
          runBtn.disabled = false;
          w.terminate();
        };
        w.postMessage(null);
      };
    </script>
  </body>
</html>

結果

ケース1:回転する「動き続けるUI」と重い処理(ループ)の参考イメージ

  • このケースでは、ボタン押下後も赤い四角の回転が途切れないので、「重い処理」をWorkerへ逃がしたときの体感が掴みやすいです。
  • 「経過時間(メイン)」が止まらず増え続けるのは、メインがイベントループと描画を進め続けている証拠となります。
  • 最後に表示される巨大な累計は、約2億ステップ分のループを終えた結果の完了の目印です。端末性能により待ち時間は変わります。長すぎる場合はWorker内のnを小さく調整してください。

ケース2:進捗の逐次通知(重い処理のプログレスバー)

Worker内でループを区切り、何%進んだかを何度かメインへ送り、画面上のプログレスバーだけを更新してみます。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>ケース2:進捗の逐次通知(Worker)</title>
  </head>
  <body>
    <div>
      <small>
        この例は、Workerから「いま何%まで進んだか」を何回か受け取り、プログレスバーだけを更新するサンプルです。<br />
        見どころは、重い計算中でも <b>バーが段階的に伸びる</b> 点です。<br />
        ※最後に返ってくる
        <code>work</code>
        は、Worker側の重いループで作った「最終的な計算値」です。<br />
        処理時間ではなく、あくまで「最後まで計算が走り切った」ことを確認するために表示しています(中身を解釈する必要はありません)。<br />
      </small>
    </div>
    <div id="bar" style="height:12px;width:0;background:#06c;"></div>
    <p id="pct">0%</p>
    <button type="button" id="run">Workerで進捗付き処理</button>
    <script>
      const runBtn = document.getElementById("run");
      runBtn.onclick = () => {
        if (runBtn.disabled) return;
        runBtn.disabled = true;
        document.getElementById("bar").style.width = "0%";
        document.getElementById("pct").textContent = "0%";

        const src = `
        self.onmessage = () => {
          const steps = 5;
          let work = 0;
          for (let s = 1; s <= steps; s++) {
            let x = 0;
            for (let i = 0; i < 80000000; i++) x += i % 7;
            work = x;
            self.postMessage({ progress: Math.round((s / steps) * 100) });
          }
          self.postMessage({ done: true, work });
        };
      `;
        const w = new Worker(
          URL.createObjectURL(
            new Blob([src], { type: "application/javascript" }),
          ),
        );
        w.onmessage = (e) => {
          if ("progress" in e.data) {
            const p = e.data.progress;
            document.getElementById("bar").style.width = p + "%";
            document.getElementById("pct").textContent = p + "%";
          }
          if (e.data.done) {
            document.getElementById("pct").textContent +=
              " (完了 / work=" + e.data.work + ")";
            runBtn.disabled = false;
            w.terminate();
          }
        };
        w.onerror = function () {
          document.getElementById("pct").textContent += " (Workerエラー)";
          runBtn.disabled = false;
          w.terminate();
        };
        w.postMessage(null);
      };
    </script>
  </body>
</html>

結果

ケース2:進捗の逐次通知(重い処理のプログレスバー)の参考イメージ

  • 青いバーの幅が20% → 40% → … → 100%のように段階的に伸びる(Workerから進捗メッセージが届くたびに更新)。
  • 完了後に「(完了)」が付く。内側のループ回数は端末に合わせて調整してください(軽すぎると一瞬で終わり、重すぎると待ち時間が長くなります)。
  • 実行中はボタンが無効化され、連打でWorkerが複数起動しにくいようにしています。

補足: 進捗通知を細かくしすぎると、メインとWorkerの間のやり取りの回数が増え、かえってオーバーヘッドになることがあります。実務では一定間隔・一定チャンクごとに送るのが無難です。

7.使い分け:Workerを使うべきとき・避けた方がよいとき

検証ツールで処理負荷を“測定”して可視化する

ブラウザの検証ツールを使うと処理負荷を可視化することができ、一層イメージが持ちやすいです。

  1. DevToolsを開く → Performance
    DevToolsを開く → Performance
  2. 記録開始(Record)→ サンプルのボタンを押して重い処理を走らせる → 停止
    記録開始(Record)→ サンプルのボタンを押して重い処理を走らせる → 停止
  3. タイムライン上で、メインスレッドの Long Task(長いタスク) や、描画(Frames)の詰まり方を確認する
    タイムライン上で、Worker処理が確認できる

「メインで実行した場合」と「Workerに逃がした場合」で、メインスレッド上の長い塊の出方が変わるのがポイントです。

ここまででWorkerの便利な動きについて紹介してきましたが、
「何でもWorkerに投げればよい」わけではありません。
どんな機能にも向き不向きがあり、Workerも例外ではありません。
それぞれ一部を紹介します。

Workerが向いている処理の例

  • 数十万件以上の配列のフィルタリング・集計・変換
  • 画像やバイナリの加工、暗号処理など、DOMに触れない重い計算
  • インタラクティブなUIの応答性を優先し、メインスレッドの占有時間を短く保ちたい場面

Workerが向かない、または慎重になる処理の例

  • 数ミリ秒で終わる軽い計算(起動とメッセージのオーバーヘッドの方が大きくなることがある)
  • DOMを頻繁に更新する処理(Workerからは直接操作できないため、結果をメインへ戻して反映が必要)
  • 巨大なデータを高頻度で往復させる処理(コピーまたは転送戦略の設計が必須)

8.さらに高度な活用:転送可能オブジェクト(Transferable)

巨大なバイナリデータ(型付き配列の裏側のバッファなど)をWorkerへ送る場合、デフォルトの複製ではメモリと時間を消費します。メッセージを送るAPIのオプションで転送リストを渡すと、バッファの所有権をWorker側へ移し、コピーを避けられる場合があります(転送後、メインスレッド側ではそのバッファは利用できなくなります)。
高度な機能となるため、ここではほんの少しだけ、一部を紹介します。

// メイン側(例:100MBのバッファを転送)
const buffer = new Uint8Array(1024 * 1024 * 100).buffer;
myWorker.postMessage({ type: 'binary', payload: buffer }, [buffer]);
// 転送後、メインスレッド側では buffer はデタッチされ、触れない想定になる

Worker側では、メインが送ったデータの中身として受け取ります。用途に応じて複数スレッドで同じメモリを共有する仕組みを検討する道もありますが、サイト全体のセキュリティ設定(クロスオリジン隔離など)が必要になってくるため、まずは同一オリジンでさまざまな使い方を模索してみるのが良いと思います。

9.まとめ

Web Workerを使うと、JavaScriptのシングルスレッドという制約のもとでも、メインスレッドの占有時間を減らし、UXと重い計算の両立を狙いやすくなります。

  1. ボトルネックの特定:ブラウザの開発者ツールで、長いタスクがどこで発生しているかを把握する(性能分析の画面や、計測用のAPIを使う方法があります)。
  2. 純粋な計算の分離:画面の要素を直接いじらない処理をWorkerへ切り出す。
  3. メッセージ設計:やり取りするデータのサイズと頻度を抑え、必要なら後述の「転送」による最適化で転送コストを下げる。

「ブラウザ向けJavaScriptは重い処理に向かない」と言われがちですが、Web Workerのような標準機能を適切に組み合わせれば、フロントエンドでも実用的なアプリケーションを構築できます。

ループ回数は端末の性能などで体感が変わります。動かしてみて「差が分かりにくい」と感じたら、本記事の3節目のブロック実験だけループを半分にしてみたり、5節目のサンプルにてWorker側はそのまま、といった片側だけ編集してみるなどの比較を試してみてください。私も記事を整えるときは、何度かこの比較に立ち返って文言を直しました。開発者ツールの性能パネルを開いたまま読み返すと、長いタスクの見え方もイメージしやすくなるはずです。是非試してみてください。

執筆者

中川 拓哉(NTT西日本 デジタル革新本部 デジタル改革推進部所属)
NTT西日本のWebアプリケーションの開発・運営に従事。
好きな技術スタック:TypeScript, Vue.js, GraphQL, Laravel

参考資料・出典

本記事を執筆するにあたり、以下のサイト・資料を参考にしました。

商標

  • 「JavaScript」は、Oracle Corporation およびその子会社の米国およびその他の国における商標または登録商標です。
  • 「Google Chrome」は、Google LLC の商標です。
  • 記載のその他の会社名・製品名は、それぞれ各社の商標もしくは登録商標です。

© NTT WEST, Inc.