😇

Maximum number of concurrent React renderers exceeded が発生する場合の対処法

2023/01/20に公開

この記事では React でストリームを返す API を使用して SSR を行う場合に発生する可能性のある Maximum number of concurrent React renderers exceeded への対処法を解説する。

TL;DR

React で renderToPipeableStream や renderToNodeStream などの Readable を返す API を使用して SSR を行う場合には、SSR が完了する前にタイムアウトなどでクライアントがリクエストを打ち切った場合には手動で Readable をクローズする必要がある。適切にクローズできていない場合は React が内部で管理している threadID が増加していき、しきい値である 65535 を超えると SSR に失敗するようになる。
対処法としては SSR 前や SSR 中にクライアントから切断された場合には React が生成する Readable を手動でクローズすれば良い。

発生条件について考察する

この問題を観測したときのエラーログには

(Error) Minified React error #304; visit https://reactjs.org/docs/error-decoder.html?invariant=304 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.

と出力されていた。ログに記載された URL を開くとより詳細なエラー内容について確認でき、以下の内容が記載されていた。

Maximum number of concurrent React renderers exceeded.
This can happen if you are not properly destroying the Readable provided by React.
Ensure that you call .destroy() on it if you no longer want to read from it, and did not read to the end. If you use .pipe() this should be automatic.

書かれている内容から推察すると、SSR を行うために使用する renderToPipeableStream や renderToNodeStream などの API が返す Readable が適切にクローズできておらず、並列する Readable の数が React 内部の許容値を超えたためにエラーが発生したようである。しかし実際に動いているコードベースを確認したところ、

ReactDOMServer.renderToNodeStream(<Hoge {...props} />).pipe(res);

の形式になっており、上記メッセージの If you use .pipe() this should be automatic. を信用するのであれば問題ないコードに見える。
React 内部で当該エラーが出力されるコードを探してみると以下のコードがヒットした。
https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/react-dom/src/server/ReactThreadIDAllocator.js#L28-L34

このコードを読む限りリークした Readable が0x10000個(=65536個)に到達した場合にエラーとなるようだ。このエラーが発生したアプリケーションはデプロイからエラー発生までの間には65536を優に超えるリクエストをさばいていたことから、ユーザーからの通常のリクエストでは問題なく Readable をクローズできているようであり、前述の If you use .pipe() this should be automatic. は正しそうである。(さすがにこれでエラーになっていたら React ユーザーに騒がれてすぐに修正されているだろうし納得の結果である)

次に考えたのは SSR 中に何らかのエラーが発生して SSR が失敗した場合である。これについては以下の結果を持って否定された。

  1. SSR に失敗したことを示すエラーログが出力されていなかった
  2. 実際にエラーを発生させるサンプルコードで実験をして問題なく Readable がクローズされることを確認した

これらの結果を踏まえて React 側に問題はないと判断を下し、Node.js の http サーバー側の問題によって Readable を適切にクローズできていないのではないかと考えた。そこで React を排除した実験用のアプリケーションを作成して Readable が適切にクローズできていないケースを探すこととした。

import { createServer } from "node:http";
import { Readable } from "node:stream";

const delay = (ms) => new Promise(resolve => { setTimeout(resolve, ms) });

createServer(async (req, res) => {
  req.on("close", () => {
    console.log("client close");
  });

  // renderToNodeStream 相当のもの
  const readableStream = new Readable({
    read(){}
  });
  readableStream.on("close", () => {
    console.log("readable close");
  });

  readableStream.pipe(res);

  await delay(1000);
  readableStream.push("hello");
  readableStream.push(null);
}).listen(3000);

このアプリケーションではクライアントからリクエストがあると以下のように動作する
1.Readable を作成し、レスポンスを返すための Writable に pipe する
2.1秒待機
3. hello という文字列を Readable に書き込み、クライアントにレスポンスとして返す

このアプリケーションに対して curl でリクエストを送信すると、

curl localhost:3000
hello

と hello のレスポンスが返り、サーバー側のログには

readable close
client close

と表示され、適切に Readable がクローズされていることがわかる。
今度は条件を変えて、レスポンスが返ってくる前に curl をタイムアウトさせてクライアント側から切断させるようにして実験をしてみる。

curl localhost:3000 -m 0.5
curl: (28) Operation timed out after 504 milliseconds with 0 bytes received

するとサーバー側のログには

client close

とだけ表示されており、 Readable が適切にクローズできていない結果となった。
この実験結果を元に React で SSR をする際にも同様にタイムアウトを短く設定してクライアント側から切断させてみたところ、React 内部の threadId が増加していくことが確認できたため、SSR 中にクライアント側から接続を切断されることが原因であることが確定した。

対処方法

前述の調査結果から、SSR 中にクライアントから切断された際には React が生成した Readable を手動でクローズしてやれば良いことがわかる。以下に express.js の middleware を使用している場合の実装例を記載する。

import { renderToNodeStream } from "react-dom/server";
return (req, res, next) => {
  const readableStream = renderToNodeStream(<div />);
  req.on("close", () => {
    readableStream.destroy();
  });
  readableStream.pipe(res);
};

ただしこのコードだと close イベントのリスナーが設定される前にクライアントから切断された場合にうまく動作してくれず片手落ち状態である。この問題への対処としては SSR 開始前に req.destroyed の boolean を参照して SSR を行わないようにすれば良い。

import { renderToNodeStream } from "react-dom/server";
return (req, res, next) => {
  if (req.destroyed) {
    return next();
  }
  const readableStream = renderToNodeStream(<div />);
  req.on("close", () => {
    readableStream.destroy();
  });
  readableStream.pipe(res);
};

Discussion