サーバーのベンチマーク

node と bun で、待受けの相手としてのメモリ・say ・ recent ・ 配信の遅れを測った

📅 作成: 2026-09-12 / 更新: 2026-09-12

サーバーは Windows サービスとして常駐し、待受けの相手をしている。常駐するものなのでメモリがいちばん効く。そこに say ・ recent の往復と、発言を待受けへ配る遅れを足して測った。

測ったのは node と bun の 2 つだけである。サーバーは TypeScript (node ・ bun)で進めると決めたため、他の処理系は土俵に載せていない。

目次

  1. 分かったこと
  2. 待受けの相手としてのメモリ
  3. say と recent
  4. 配信の遅れ
  5. 測り方
  6. 測り直しになった話

分かったこと

メモリは、どちらを見るかで勝者が入れ替わる。待受け 10 本で、実メモリは node 56.9 MB ・ bun 37.8 MB (bun が 19 MB 軽い)。ところが確保量は node 31.1 MB ・ bun 135.1 MB で、bun が 4.3 倍を抱える。
say ・ recent ・ 配信の遅れには差が無い。どれも SQLite と配信の仕組みが決めていて、処理系を替えても動かない。

項目nodebun読み方
起動 (待ち受け開始まで)152.4 ms151.0 ms測るたびに動く。常駐するので効かない
実メモリ 待受け 0 本54.5 MB32.0 MBbun が 22.5 MB 軽い
実メモリ 待受け 10 本56.9 MB37.8 MBbun が 19.1 MB 軽い
確保量 待受け 0 本29.0 MB129.5 MBnode が 100.5 MB 軽い
確保量 待受け 10 本31.1 MB135.1 MBnode が 104.0 MB 軽い
say3.18 ms3.13 ms差なし
recent 50 件0.33 ms0.34 ms差なし
recent 500 件1.31 ms0.98 msわずかに bun
配信 10 本へ27.95 ms26.35 ms差なし

待受けの相手をするコストは、ほとんど無かった

測る前は「待受けを何本も抱えると重くなる」と思っていたが、10 本ぶら下げても node で 2.08 MB、bun で 5.93 MB しか増えなかった。1 本あたり 0.21 MB ・ 0.59 MB である。

常駐コストを決めているのは、ぶら下がっている本数ではなく処理系そのものだった。そこで 22.9 MB の差が付いている。

1 本あたりの伸びは bun のほうが 2.8 倍きつい。それでも出発点が 22.9 MB 低いので、追いつかれるのは 59 本ぶら下がったときである。
待受けは 1 プロジェクトにつき 1 本。現実に並ぶのは数本なので、この交差点には届かない。

速さを決めているのは処理系ではない

操作かかる時間何が決めているか
say3.1 msSQLite への書き込み。処理系を替えても動かない
recent 50 件0.33 ms読むだけ。十分速い
recent 500 件1.1 ms件数に比例。10 倍にして 3 倍
配信2.2 ms/本待受けを 1 本ずつ起こす手間

say の 3.1 ms は読み取りの 10 倍かかっている。WAL に同期で書いているためで、ここを速くしたいなら処理系ではなく書き方を変えることになる。ただし人が待つ時間としては十分短い。

どちらのメモリを見るか

実メモリだけを見て「bun のほうが軽い」と決めると読み違える。確保量では逆に 4.3 倍を抱えている。

指標nodebun勝つのは
実メモリ (待受け 10 本)56.9 MB37.8 MBbun (19.1 MB 差)
確保量 (待受け 10 本)31.1 MB135.1 MBnode (104.0 MB 差)

空きメモリが減ったときに効くのは確保量のほうである。実メモリはページアウトされて減るが、確保量は減らない。
Claude Code が背面のコマンドを刈るのは空きが減ったときで、待受けが [killed] で落ちる原因になっている(i260901-07)。この観点では node のほうが安全である。

CLI を測ったときも同じ傾向だった。bun は実メモリ 25.59 MB で C# と並びながら、確保量は 108.43 MB で 5 実装のうち最大だった。処理系の癖であり、サーバーでも CLI でも変わらない。

確保量は Windows でしか測っていない。聞き方が OS ごとに違う (Windows は Get-Process、Linux は /proc/<pid>/status、Mac は ps や vmmap)。実メモリのほうは process.memoryUsage() で揃えているので、どの OS でも同じ形で測れる。

待受けの相手としてのメモリ

待受けを 0 ・ 1 ・ 5 ・ 10 本ぶら下げ、それぞれで落ち着くのを待ってから 3 秒ぶんを平均した。

待受けの本数とサーバーの実メモリ (MB・小さいほど良い) 015304560 0 本1 本5 本10 本 ぶら下がっている待受けの本数 node 実メモリ (青) bun 実メモリ (緑) 54.556.9 32.037.8
図 1: 待受けを増やしてもほとんど伸びない。差は出発点に付いている

確保量では逆になる

待受け 10 本のとき (MB・小さいほど良い) 04080120160 node 実メモリ bun 実メモリ node 確保量 bun 確保量 56.9 37.8 31.1 135.1
図 2: 実メモリでは bun が小さいが、確保量では node が 4.3 分の 1 で済む
待受けnode 実bun 実node 確保bun 確保
0 本54.52 MB31.99 MB29.00 MB129.49 MB
1 本54.89 MB35.11 MB29.27 MB131.77 MB
5 本55.50 MB36.43 MB29.79 MB133.21 MB
10 本56.91 MB37.79 MB31.13 MB135.12 MB
1 本あたりの伸び0.24 MB0.58 MB0.21 MB0.56 MB

なぜ伸びないか

待受けは /api/poll にぶら下がったまま何もしない。サーバー側が抱えるのは TCP の接続 1 本と、いつ起こすかを覚えておく分だけである。発言が来るまで処理は動かない。

1 本あたり node 0.24 MB ・ bun 0.58 MB。bun のほうが 1 本を重く持つが、実メモリでは出発点の差が大きいので順位は変わらない。

どこで追いつかれるか

実メモリの伸び方がそのまま続くとして解くと、66 本で交差する。

node   54.52 + 0.24 × n
bun    31.99 + 0.58 × n
                       → n ≒ 66

待受けは 1 プロジェクトにつき 1 本と決めている。66 本が並ぶことは無い。

確保量のほうには交差点が無い。出発点で node が 100.5 MB 少なく、伸び方もほぼ同じ (node 0.21 MB ・ bun 0.56 MB)。何本ぶら下げても node のほうが少ないままである。

say と recent

人が待つのはここである。say は 200 回、recent(/api/history)は件数を変えて 200 回ずつ測った。

1 回あたりの往復 (中央値 ms・小さいほど良い) 01234 say (node) say (bun) recent 50 (node) recent 50 (bun) recent 500 (node) recent 500 (bun) 3.18 3.13 0.33 0.34 1.31 0.98
図 3: node (青)と bun (緑)で差が出ていない。決めているのは DB のほう
操作処理系中央値p95最小最大
saynode3.184.572.269.46
bun3.134.552.296.08
recent 50 件node0.330.540.291.05
bun0.340.460.291.88
recent 500 件node1.312.511.183.53
bun0.981.950.823.93

単位は ms。

書くのは読むより 10 倍かかる

say 3.1 ms に対して recent 50 件が 0.33 ms。差は SQLite に同期で書いているぶんである。読むほうはメモリの上で済むことが多い。

件数を 50 から 500 へ 10 倍にしても、かかる時間は 3 倍にしかならない。問い合わせ自体の手間が大きく、件数はその上に乗るだけだからである。

待受けを抱えたままでも遅くならない

状況nodebun
誰もぶら下がっていない3.18 ms3.13 ms
待受けが 10 本ぶら下がっている3.02 ms3.02 ms

別のルームへ打って、起こす処理が混ざらないようにして測った。抱えているだけでは何も起きない。

配信の遅れ

say を打ってから、ぶら下がっている待受けが全員返るまでを測った。人から見ると「送った」から「相手に出た」までである。

say から全員に届くまで (中央値 ms・小さいほど良い) 010203040 1 本5 本10 本 ぶら下がっている待受けの本数 node (青) bun (緑・破線) 5.616.128.0
図 4: 本数にきれいに比例する。node と bun はほぼ重なる
待受けnodebun1 本あたり
1 本5.55 ms5.83 ms—
5 本16.12 ms16.27 ms2.6 ms
10 本27.95 ms26.35 ms2.2 ms

読み取れること

本数に比例する。1 本増えるごとに 2.2 〜 2.6 ms。待受けを 1 本ずつ起こし、その相手ごとに返すものを作っているためである。

最初の 1 本に 5.6 ms かかるのは、say 自体の 3.1 ms が含まれているから。書き終えてから起こしにいく。

10 本で 28 ms なら、人には分からない。並ぶのはせいぜい数本なので、いまの作りで困る場面は無い。
比例するということは、本数が 2 桁になれば効いてくるという意味でもある。100 本なら 220 ms。そこまで増やすなら配り方を変える話になる。

測り方

項目版
OSMicrosoft Windows NT 10.0.26200.0
Node.jsv26.8.1
bun1.4.2
測った日2026-09-12

使ったもの

置き場中身
tools/40_test/benchmark-server.mjs本体。結果を tmp/benchmark-server.json に書く
tools/40_test/server-probe.mjsサーバーを同じプロセスで立て、メモリを報告する

メモリは、外からではなく中から測る

別のプロセスのメモリを測る手立ては OS ごとに違う。Windows は tasklist ・ WMI、Mac ・ Linux は ps になる。Mac ・ Linux にも展開するので、測り方が OS で分かれる形は持ちたくない。

そこで サーバーを子プロセスの中で import し、そのプロセス自身に process.memoryUsage() を呼ばせた。node でも bun でも、どの OS でも同じ 1 本のコードで済む。

// server-probe.mjs
await import(pathToFileURL(join(root, 'src', 'server', 'main.mjs')).href);
process.stdout.write('__READY__\n');
setInterval(() => {
	const m = process.memoryUsage();
	process.stdout.write(`__MEM__ ${m.rss} ${m.heapUsed} ${m.heapTotal} ${m.external}\n`);
}, 250);

親は標準出力の印つきの行だけを拾う。サーバー自身のログも同じ出力に混ざるので、印で選り分けている。

本番に触らない作りにする

守りやり方
置き場tmp/bench-server/<処理系>/_data を渡す。既定でなければテスト用として立つ
ポートOS に空きを割り当てさせる。番号をソースに書かないので、本番へ飛ぶ打ち間違いが起きない
立った後probe が本番の置き場だったら、測る前に exit 3 で止める
終わったら置き場ごと捨てる

ベンチマークは say を 800 回以上打つ。置き場を渡し損ねて本番の DB を掴むと、そのまま残る。
だから probe 側にも確かめを置いた。渡す側の注意に頼らず、立った先で止める。

測る手順

何をどう測ったか
起動プロセスを作ってから __READY__ が出るまで
実メモリ本数ごとに 2 秒落ち着かせ、3 秒ぶん (250 ms 間隔)を平均
確保量そのあと Get-Process に 5 回聞いて平均。PowerShell は 1 回だけ起こす(250 ms ごとに起こすと、起動 185 ms のほうが間隔より長くなる)
say ・ recent捨てる 20 回のあと 200 回。recent は先に 600 件積んでから
配信張り直しては 1 回打つ、を 10 回

この測定の限界

測り直しになった話

最初の測定は捨てた。サーバーではなく、測っている側を測っていた。
say も recent も、件数を 10 倍にしても一律 15 msになった。そこで気づいた。

気づいた形

測ったものnode の値bun の値
say15.46 ms15.37 ms
recent 50 件13.64 ms15.53 ms
recent 500 件15.66 ms15.10 ms

書くのも読むのも、50 件も 500 件も同じ値になることはない。どれかが全部を律速している。

切り分け

何もしない HTTP サーバーを立てて、同じやり方で測った。

同じサーバーへの 1 往復 (中央値 ms) 0481216 node の fetch node の http.request bun の fetch 15.21 0.18 0.09
図 5: 遅いのは node の fetch だけ。同じ node でも http.request は 85 倍速い

node の fetch だけが遅かった。最小は 0.36 ms まで下がるので、返せないのではなく待たされている。Windows の既定のタイマー解像度 15.6 ms と一致する。

直した形

ベンチマークの側を node:http の http.request(keepAlive つき)に替えた。node で走らせても bun で走らせても同じ数字が出るようになり、そこで初めて処理系の差が見えた。

測り方node サーバーの saybun サーバーの say差が見えるか
node の fetch で測る15.46 ms15.37 ms見えない
http.request で測る3.18 ms3.13 ms見える (そして差は無かった)

結論は変わらなかったが、「差が無い」と「差が見えない」は別のことである。前者は測ってから言える。

持ち帰り

ローカルの往復を node の fetch で測ってはいけない。15 ms 未満のものはすべて 15 ms に見える。
CLI も src/client/chat.mjs で fetch を使っているので、node で動かすと 1 往復ごとにこれが乗る。課題 i260912-02 に残した。

再現の手順は tests/http-client-latency.test.mjs に置いた。速さの下限は決めていない。手段によって結果が変わったときだけ落ち、往復の時間は毎回数字として出る。課題が直ったかはその数字で見る。