Bunの–watchと–hotで、保存したときの動きの違い
開発中のサーバーを、保存のたびに自動で動かし直したいとき、Bun には bun --watch と bun --hot の2つの方法があります。--watch はプログラムを最初から実行し直し、--hot は同じプロセスのままコードを読み込み直します。この違いで、メモリ上の値、HTTP サーバーの応答、setInterval のタイマーの扱いが変わります。
nodemon の代わりにどちらを使えばよいかを決められるように、私が検証環境で同じファイルを両方のモードで動かし、出力と数値を比べました。
検証環境は Ubuntu 24.04(x86_64)、Bun 1.4.4 です。保存は、スクリプトから sed などでファイルを書き換えて行いました。エディターから保存したとき、Windows と macOS、bun --watch test と bun build --watch、エントリーのファイルから import した別のファイルを書き換えたとき、フレームワークと組み合わせたときは試していません。公式ドキュメントは、検証に使った版(タグ bun-v1.4.4)の元ファイルを GitHub で確認し、リンクもそこに張っています。
違いを先に表で見る
検証で確かめた違いをまとめると、次のとおりです。
| 保存したときに | bun --watch |
bun --hot |
|---|---|---|
| プロセス | 最初から実行し直す(PID は同じ) | そのまま残る |
globalThis に置いた値 |
消える | 残る |
モジュールの中の let の値 |
最初の値に戻る | 最初の値に戻る |
| HTTP サーバー | 保存後 20〜32ms、新しい版が応答するまでリクエストが失敗する | リクエストが失敗しない |
setInterval のタイマー |
前のものは止まる | 前のものが動き続ける |
SIGTERM のハンドラー |
再起動の前に実行される | 実行されず、登録が増えていく |
| 文法エラーのまま保存 | サーバーが止まる | 前のコードのまま応答する |
公式ドキュメントの Watch Mode のページでも、--watch はプロセスを再起動(hard restart)し、--hot はプロセスを再起動せずにコードを読み直す(soft reload)と説明されています。
–watch は、同じ PID のままプログラムを最初から実行する
次の state.ts を、両方のモードで動かしました。保存するたびに version の値を "v1"、"v2"、"v3" と書き換えています。
declare global {
var reloads: number;
}
globalThis.reloads ??= 0;
let moduleCount = 0;
moduleCount++;
const version = "v0";
console.log(`[${version}] pid=${process.pid} uptime=${process.uptime().toFixed(2)}s globalThis.reloads=${globalThis.reloads} moduleCount=${moduleCount}`);
globalThis.reloads++;
setInterval(() => {}, 1_000_000);
最後の setInterval は、プログラムがすぐに終わらないようにするためのものです。bun --watch app.ts の出力は次のとおりでした(state.ts を app.ts という名前でコピーして動かしています)。
[v0] pid=2145 uptime=0.00s globalThis.reloads=0 moduleCount=1
[v1] pid=2145 uptime=0.00s globalThis.reloads=0 moduleCount=1
[v2] pid=2145 uptime=0.00s globalThis.reloads=0 moduleCount=1
[v3] pid=2145 uptime=0.00s globalThis.reloads=0 moduleCount=1
保存するたびに、起動してからの時間(uptime)が 0 秒に戻り、globalThis.reloads も 0 のままです。プログラムが毎回まっさらな状態から始まっています。
一方で、PID は 2145 のまま変わりません。そこで、strace を付けて別に起動し直し(このときの PID は 2195)、プログラムを実行するシステムコール execve の呼び出しを記録しました。execve は、いま動いているプログラムを新しいプログラムに入れ替えるものです(Linux の execve の man ページ)。bun の場所は /path/to/bun に置き換えています。
2195 execve("/path/to/bun", ["bun", "--watch", "app.ts"], 0x7ffe03994888 /* 6 vars */) = 0
2196 execve("/path/to/bun", ["bun", "--watch", "app.ts"], 0x2ce87530000 /* 6 vars */ <unfinished ...>
...
2195 +++ superseded by execve in pid 2196 +++
1行目は最初に起動したときの記録です。2行目が保存したときのもので、bun --watch app.ts が同じ引数で実行し直されています。最後の行は、2195 のプログラムが、この execve で入れ替わったことを示しています。このときもプログラムが出力した PID は、保存の前後とも 2195 でした。
PID が変わらないので、PID だけを見て「再起動していない」と判断しないように気をつけてください。
–hot は、プロセスを残したままコードを評価し直す
同じ state.ts を bun --hot app.ts で動かすと、出力は次のようになりました。
[v0] pid=2175 uptime=0.00s globalThis.reloads=0 moduleCount=1
[v1] pid=2175 uptime=1.00s globalThis.reloads=1 moduleCount=1
[v2] pid=2175 uptime=2.01s globalThis.reloads=2 moduleCount=1
[v3] pid=2175 uptime=3.01s globalThis.reloads=3 moduleCount=1
uptime は保存のたびに約1秒ずつ増え、globalThis.reloads も 1、2、3 と増えています。プロセスは止まらず、globalThis に置いた値が残っています。
ただし、モジュールの中で let で宣言した moduleCount は、毎回 1 に戻っています。--hot はファイルの中身を最初から評価し直すので、残るのは globalThis に置いたものだけです。公式ドキュメントにも、すべてのファイルを評価し直し、globalThis などのグローバルな状態は残ると書かれています。
HTTP サーバーでは、–watch のときだけ応答が途切れる
Bun.serve で作った小さなサーバーでも比べました。受けたリクエストの数を hits に数えて返します。
let hits = 0;
const version = "v0";
const server = Bun.serve({
port: 3000,
fetch() {
hits++;
return new Response(`${version} hits=${hits}\n`);
},
});
console.log(`${version} listening on ${server.port} pid=${process.pid} at ${Date.now()}`);
curl で3回アクセスしてから保存し、もう一度3回アクセスすると、--watch でも --hot でも結果は同じでした。
v0 hits=1
v0 hits=2
v0 hits=3
--- save (v0 -> v1)
v1 hits=1
v1 hits=2
v1 hits=3
hits はモジュールの中の let なので、--hot でも 0 に戻ります。保存してもリクエストの数を数え続けたいなら、globalThis に置く必要があります。
違いが出たのは、保存した直後です。2ミリ秒ずつ間を空けてサーバーへ続けてリクエストを送りながらファイルを保存し、新しい版(v1 など)が返ってくるまでの時間と、その間に失敗したリクエストの数を、10回ずつ測りました。

--watch では、新しい版が応答するまで 20〜32ms かかり、その間のリクエストは 7〜12件が失敗しました。各回の最初の失敗は、接続できないエラー(Python の URLError)か、接続が切られるエラー(ConnectionResetError)でした。--hot では 3〜5ms で新しい版が応答し、失敗は10回とも0件でした。
–hot では、前のタイマーと process.on のハンドラーが残る
--hot には、気をつけたい点があります。次の timer.ts で確かめました。
const version = "v0";
setInterval(() => {
console.log(`tick from ${version}`);
}, 400);
process.on("SIGTERM", () => {
console.log(`SIGTERM handler ran in ${version}`);
process.exit(0);
});
console.log(`${version} started, SIGTERM listeners=${process.listenerCount("SIGTERM")}`);
約1秒ごとに2回保存したときの、bun --hot app.ts の出力です。
v0 started, SIGTERM listeners=1
tick from v0
tick from v0
v1 started, SIGTERM listeners=2
tick from v0
tick from v1
tick from v0
tick from v1
tick from v0
v2 started, SIGTERM listeners=3
tick from v1
tick from v0
tick from v2
...
保存したあとも、前の版(v0)のタイマーが止まらずに動き続けています。2回保存すると、v0、v1、v2 の3つのタイマーが同時に動きました。process.on で登録したハンドラーも、SIGTERM listeners が 1、2、3 と増えています。
同じファイルを bun --watch app.ts で動かすと、次のようになりました。
v0 started, SIGTERM listeners=1
tick from v0
tick from v0
SIGTERM handler ran in v0
v1 started, SIGTERM listeners=1
tick from v1
tick from v1
SIGTERM handler ran in v1
v2 started, SIGTERM listeners=1
tick from v2
tick from v2
--watch では、前の版のタイマーは止まり、ハンドラーはいつも1つです。再起動の前に SIGTERM のハンドラーが実行されています。公式ドキュメントにも、bun run --watch は再起動の前に、スクリプトが登録した終了のシグナル(既定は SIGTERM)のハンドラーを実行すると書かれています。
--hot で動かしているコードの中で setInterval を使うと、保存するたびに同じ処理が1つずつ増えて同時に動きます。定期的に外部の API を呼ぶ処理などでは、呼び出しの回数がその分だけ増えます。
--hot で前のタイマーを止めるには、タイマーの ID を globalThis に残しておき、次に読み込まれたときに clearInterval で止めます。
declare global {
var tickTimer: ReturnType<typeof setInterval> | undefined;
}
const version = "v0";
// 読み込み直す前のタイマーが残っていたら止める
if (globalThis.tickTimer) clearInterval(globalThis.tickTimer);
globalThis.tickTimer = setInterval(() => {
console.log(`tick from ${version}`);
}, 400);
console.log(`${version} started`);
このファイルを bun --hot app.ts で動かし、同じように2回保存すると、動いているタイマーはいつも1つになりました。
v0 started
tick from v0
tick from v0
v1 started
tick from v1
tick from v1
v2 started
tick from v2
tick from v2
文法エラーのまま保存すると、–watch ではサーバーが止まる
書きかけのまま保存してしまったときの動きも比べました。先ほどの server.ts の最後の }); を ) にして保存し、そのあとで正しいファイルに戻しています。
どちらのモードでも、保存した時点で次のエラーが出ました(パスは /path/to/app に置き換えています。同じ形のエラーがあと2つ続きます)。
10 | )
^
error: Expected identifier but found ")"
at /path/to/app/app.ts:10:1
エラーが出ている間のサーバーの様子は違いました。--watch では、curl がサーバーにつながりませんでした。
curl: (7) Failed to connect to localhost port 3000 after 0 ms: Couldn't connect to server
--hot では、エラーになる前の v0 のコードのまま応答が返りました。
v0 hits=2
正しいファイルに戻して保存すると、どちらのモードでも v2 hits=1 が返りました。--watch でもサーバーは自動で動き出し、コマンドを打ち直す必要はありません。
どちらを使うかの目安
今回の結果から考えると、HTTP サーバーを書き換えながら確かめるときは --hot が向いています。保存の直後でもリクエストが失敗せず、書きかけで保存しても前のコードで動き続けるからです。ただし、setInterval や process.on を使うコードは、上の例のように前のものを止める書き方が必要になります。
--watch は、毎回まっさらな状態から始まるので、前の状態が残ることによる思わぬ動きがありません。タイマーやイベントのハンドラーが多いコードや、起動するときの処理まで含めて確かめたいときは、こちらのほうが結果を読み取りやすくなります。
なお、公式ドキュメントによると、--hot はブラウザーの画面を書き換えるホットリロードとは別物で、サーバー側で使うためのものです。ブラウザー側のホットリロードには、Vite などのフレームワークを使うよう案内されています。





