Spring MVC で Server-Sent Events(SSE:サーバーからブラウザへ、1本の HTTP 接続で次々にイベントを送る仕組み)を返すには、コントローラーで SseEmitter を返します。 WebFlux なら Flux<ServerSentEvent<T>> を返します。

@GetMapping(path = "/notices", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter notices() {
    SseEmitter emitter = new SseEmitter(30 * 60 * 1000L);   // 30分でタイムアウト
    // 別のスレッドから emitter.send(...) で送る
    return emitter;
}

本番でつまずくのは、ほぼ次の4つです。 どれもこの記事で再現しています。

つまずき 起きること 対策
タイムアウト Tomcat の既定 30 秒で接続が切れる new SseEmitter(ミリ秒) か spring.mvc.async.request-timeout
切断に気づけない 閉じたタブに送り続ける 15秒ごとのハートビート(コメント行)で送信失敗を検出
nginx のバッファリング イベントが応答の終わりまで届かない X-Accel-Buffering: no か proxy_buffering off
nginx のタイムアウト 60秒(既定)何も送らないと切られる ハートビートか proxy_read_timeout を延ばす

確認環境:SSE の動きは、SseEmitter と同じ書式(id:・event:・data:)で返す小さなサーバーを Node.js 22.22.2 で作り、curl 8.5.0、Chromium 141.0.7390.37(playwright-core 1.56.1)の EventSource、nginx 1.24.0 を通して確かめました。Spring 側のコードと既定値は、Spring Framework 7.0.9・Spring Boot 4.1.1 のソースとリファレンスで確認しています(確認日 2026年10月13日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Boot SSE|SseEmitterの使い方と再接続・nginx設定」

SSE で流れるデータの形

SSE は、text/event-stream という形式のテキストを、空行で区切って送り続けるだけの仕組みです。 SseEmitter.event() の各メソッドが、次の行を書きます(7.0.9 の SseEmitter のソース)。

SseEmitter のメソッド 書かれる行 ブラウザ側
.id("42") id:42 e.lastEventId。再接続時に Last-Event-ID ヘッダーで返ってくる
.name("notice") event:notice addEventListener("notice", …) で受ける
.data(obj) data:… e.data(文字列)
.reconnectTime(3000) retry:3000 再接続までの待ち時間(ミリ秒)
.comment("keepalive") :keepalive 無視される(ハートビートに使う)

SseEmitter.event().id("1").name("notice").data(notice, MediaType.APPLICATION_JSON) は、次のバイト列になります。: のあとに空白は入りません。 HTML の仕様では、: の直後の空白1つは取り除かれるので、data:x と data: x は同じ意味です。

id:1
event:notice
data:{"id":1,"message":"出荷しました"}

curl で、同じ書式のサーバーから受け取った生の応答です(-N でバッファリングを止めています)。

curl -N -i 'http://127.0.0.1:18611/events?count=3&interval=200&retry=3000'
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Transfer-Encoding: chunked

retry:3000

id:1
data:{"id":1,"conn":1}

id:2
data:{"id":2,"conn":1}

id:3
data:{"id":3,"conn":1}

SseEmitter のコード全文(Spring MVC)

通知を全員に配る「ハブ」を1つ作り、接続ごとの SseEmitter を集合で持ちます。 切断・タイムアウト・エラーのときに集合から外すこと、取りこぼしを Last-Event-ID で再送することまで入れています。

package com.example.sse;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class SseApplication {
    public static void main(String[] args) {
        SpringApplication.run(SseApplication.class, args);
    }
}
package com.example.sse;

import java.time.Instant;

public record Notice(long id, String message, Instant at) {
}
package com.example.sse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.atomic.AtomicLong;

@Service
public class NoticeHub {

    private static final Logger log = LoggerFactory.getLogger(NoticeHub.class);
    private static final long TIMEOUT_MS = Duration.ofMinutes(30).toMillis();
    private static final int KEEP = 100;                 // 再送用に直近100件を持つ

    private final Set<SseEmitter> emitters = ConcurrentHashMap.newKeySet();
    private final Deque<Notice> recent = new ConcurrentLinkedDeque<>();
    private final AtomicLong seq = new AtomicLong();

    /** 接続を受け付ける。lastEventId はブラウザが再接続時に送ってくる値 */
    public SseEmitter subscribe(String lastEventId) {
        SseEmitter emitter = new SseEmitter(TIMEOUT_MS);
        emitters.add(emitter);

        emitter.onCompletion(() -> emitters.remove(emitter));
        emitter.onTimeout(() -> {               // タイムアウト:こちらから閉じる(ブラウザは再接続する)
            emitters.remove(emitter);
            emitter.complete();
        });
        emitter.onError(e -> emitters.remove(emitter));

        try {
            // 接続直後に1回送る:再接続間隔の指定と、プロキシに応答を早く返すため
            emitter.send(SseEmitter.event().comment("connected").reconnectTime(3000));
            // 取りこぼしの再送
            for (Notice n : since(lastEventId)) {
                emitter.send(toEvent(n));
            }
        } catch (IOException e) {
            // 送信失敗(切断済み)。completeWithError は呼ばない(Javadoc の注記)
            emitters.remove(emitter);
        }
        return emitter;
    }

    /** 全員に配る */
    public Notice publish(String message) {
        Notice n = new Notice(seq.incrementAndGet(), message, Instant.now());
        recent.addLast(n);
        while (recent.size() > KEEP) {
            recent.pollFirst();
        }
        for (SseEmitter emitter : emitters) {
            try {
                emitter.send(toEvent(n));
            } catch (IOException | IllegalStateException e) {
                // IOException:ブラウザが閉じた。IllegalStateException:すでに complete 済み
                emitters.remove(emitter);
            }
        }
        return n;
    }

    /** 15秒ごとのハートビート。切断の検出と、プロキシのタイムアウト対策 */
    @Scheduled(fixedRate = 15_000)
    public void heartbeat() {
        for (SseEmitter emitter : emitters) {
            try {
                emitter.send(SseEmitter.event().comment("keepalive"));
            } catch (IOException | IllegalStateException e) {
                emitters.remove(emitter);
            }
        }
        log.debug("sse connections={}", emitters.size());
    }

    List<Notice> since(String lastEventId) {
        if (lastEventId == null || lastEventId.isBlank()) {
            return List.of();
        }
        long last;
        try {
            last = Long.parseLong(lastEventId);
        } catch (NumberFormatException e) {
            return List.of();
        }
        List<Notice> result = new ArrayList<>();
        for (Notice n : recent) {
            if (n.id() > last) {
                result.add(n);
            }
        }
        return result;
    }

    private SseEmitter.SseEventBuilder toEvent(Notice n) {
        return SseEmitter.event()
                .id(String.valueOf(n.id()))
                .name("notice")
                .data(n, MediaType.APPLICATION_JSON);
    }
}
package com.example.sse;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

@RestController
public class NoticeController {

    private final NoticeHub hub;

    public NoticeController(NoticeHub hub) {
        this.hub = hub;
    }

    @GetMapping(path = "/notices", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter subscribe(@RequestHeader(name = "Last-Event-ID", required = false) String lastEventId) {
        return hub.subscribe(lastEventId);
    }

    @PostMapping("/notices")
    public Notice publish(@RequestBody String message) {
        return hub.publish(message);
    }
}

publish は1件ずつ順に送るので、つながっている人数が多いと遅くなります。数百を超える接続や、複数台のサーバーで配るときは、Redis の Pub/Sub などで各サーバーに届け、各サーバーが自分の接続に送る形にします。

ブラウザ側(EventSource)

<!doctype html>
<meta charset="utf-8">
<title>通知</title>
<ul id="list"></ul>
<script>
  const es = new EventSource("/notices");
  es.addEventListener("notice", (e) => {          // event:notice の行があるイベント
    const n = JSON.parse(e.data);
    const li = document.createElement("li");
    li.textContent = `#${e.lastEventId} ${n.message}`;
    document.getElementById("list").append(li);
  });
  es.onmessage = (e) => console.log("名前なしのイベント", e.data);
  es.onerror = () => console.log("切断。readyState =", es.readyState); // 0 なら再接続中、2 なら終了
</script>

event:notice を付けたイベントは onmessage では受け取れません。 Chromium で確かめると、名前の無いイベントは onmessage に、event:price のイベントは addEventListener("price", …) にだけ届きました。

EventSource の再接続と Last-Event-ID(Chromium で実測)

EventSource は、接続が切れると自動で再接続し、最後に受け取った id を Last-Event-ID ヘッダーで送ってきます。 サーバーが2件送って接続を閉じる、という動きを繰り返させたときの記録です。

39ms    open
241ms   message id=1
443ms   message id=2
447ms   error readyState=0        ← サーバーが閉じた。0 = 再接続を待っている
1456ms  open                      ← retry:1000 の約1秒後に再接続
1654ms  message id=3
1855ms  message id=4
1856ms  error readyState=0
2880ms  open

サーバー側のログでは、再接続のリクエストに Last-Event-ID が付いていました。

#5 GET /events?... Last-Event-ID=-  Accept=text/event-stream
#6 GET /events?... Last-Event-ID=2  Accept=text/event-stream
#7 GET /events?... Last-Event-ID=4  Accept=text/event-stream

条件を変えたときの結果です(Chromium 141)。

サーバーの応答 ブラウザの動き
retry:1000 を送ってから切断 約1.0秒後に再接続
retry なしで切断 約3.1秒後に再接続(仕様では「数秒程度」で、ブラウザが決める値)
204 No Content エラー、readyState=2。再接続しない
Content-Type: application/json エラー、readyState=2。再接続しない
500 エラー、readyState=2。再接続しない
data:line1 と data:line2 の2行 e.data は "line1\nline2"(改行でつながる)

サーバーから「もう再接続しないで」と伝えるには、204 No Content を返します(HTML の仕様にもこの方法が書かれています)。逆に、500 などを返すと、一時的な障害でもブラウザは再接続しません。サーバーの再起動中などに 502 を返すプロキシがあると、そこで止まる点に注意します。

SseEmitter に改行を含む文字列を渡すと、7.0.9 のソースでは data: の行に分けて書きます。上の表のとおり、ブラウザ側では元の改行入りの文字列に戻ります。

タイムアウトの決まり方

new SseEmitter()(引数なし)は、Spring MVC の設定、それも無ければサーブレットコンテナの既定値を使います。 Tomcat の非同期処理の既定は 30 秒です。

設定 効く範囲 値
new SseEmitter(1_800_000L) その接続だけ ミリ秒
spring.mvc.async.request-timeout: 30m アプリ全体(引数なしの SseEmitter) Duration
何も設定しない ― コンテナの既定(Tomcat は 30 秒)
new SseEmitter(0L) など 0 以下 その接続だけ タイムアウトなし(Servlet の AsyncContext.setTimeout の決まり)

タイムアウトなしにすると、切断に気づけない接続が残り続けることがあります。 30分などの上限を付け、onTimeout で complete() し、ブラウザの自動再接続と Last-Event-ID でつなぎ直してもらう形が扱いやすくなります。

spring:
  mvc:
    async:
      request-timeout: 30m

切断の検出

Spring Framework のリファレンスには、「Servlet API は、クライアントが去ったことを通知しない」とあります。送ろうとして失敗したときに初めて分かるので、送るものが無い間もハートビートを送ります。HTML の仕様も、プロキシに切られないよう 15秒ごと程度のコメント行を勧めています。

send が IOException で失敗したときは、completeWithError を呼ぶ必要が無いと ResponseBodyEmitter の Javadoc にあります(コンテナからの通知で Spring MVC が後始末をするため)。上のコードでは、集合から外すだけにしています。

WebFlux のコード全文(Flux<ServerSentEvent>)

WebFlux では、Flux<ServerSentEvent<T>> を返します。 ServerSentEvent.builder() の id・event・data・retry・comment が、それぞれの行になります。Spring MVC でも Flux<ServerSentEvent<T>> を返せます(リファレンスの非同期処理の章)。

package com.example.ssewebflux;

import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;

import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.atomic.AtomicLong;

@RestController
public class NoticeStreamController {

    public record Notice(long id, String message, Instant at) {
    }

    private static final int KEEP = 100;

    // 購読者がいないときは捨て、遅い購読者は待たない
    private final Sinks.Many<Notice> sink = Sinks.many().multicast().directBestEffort();
    private final Deque<Notice> recent = new ConcurrentLinkedDeque<>();
    private final AtomicLong seq = new AtomicLong();

    @GetMapping(path = "/notices", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<Notice>> stream(
            @RequestHeader(name = "Last-Event-ID", required = false) String lastEventId) {

        ServerSentEvent<Notice> hello = ServerSentEvent.<Notice>builder()
                .comment("connected")
                .retry(Duration.ofSeconds(3))
                .build();

        Flux<ServerSentEvent<Notice>> replay = Flux.fromIterable(since(lastEventId)).map(this::toSse);
        Flux<ServerSentEvent<Notice>> live = sink.asFlux().map(this::toSse);
        Flux<ServerSentEvent<Notice>> heartbeat = Flux.interval(Duration.ofSeconds(15))
                .map(i -> ServerSentEvent.<Notice>builder().comment("keepalive").build());

        return Flux.concat(Mono.just(hello), replay)
                .concatWith(Flux.merge(live, heartbeat));
        // ブラウザが切断すると購読がキャンセルされ、interval も止まる
    }

    @PostMapping("/notices")
    public Notice publish(@RequestBody String message) {
        Notice n = new Notice(seq.incrementAndGet(), message, Instant.now());
        recent.addLast(n);
        while (recent.size() > KEEP) {
            recent.pollFirst();
        }
        sink.tryEmitNext(n);
        return n;
    }

    private List<Notice> since(String lastEventId) {
        if (lastEventId == null || lastEventId.isBlank()) {
            return List.of();
        }
        long last;
        try {
            last = Long.parseLong(lastEventId);
        } catch (NumberFormatException e) {
            return List.of();
        }
        List<Notice> result = new ArrayList<>();
        for (Notice n : recent) {
            if (n.id() > last) {
                result.add(n);
            }
        }
        return result;
    }

    private ServerSentEvent<Notice> toSse(Notice n) {
        return ServerSentEvent.<Notice>builder()
                .id(String.valueOf(n.id()))
                .event("notice")
                .data(n)
                .build();
    }
}
項目 SseEmitter(MVC) Flux<ServerSentEvent>(WebFlux)
依存 spring-boot-starter-webmvc spring-boot-starter-webflux
スレッド 接続ごとに処理は解放、送信は自分のスレッドから イベントループ。接続数が多くても軽い
タイムアウト あり(既定はコンテナの値) 非同期のタイムアウトは無い
切断の検出 送信の失敗で知る 購読のキャンセルで知る(doOnCancel)
既存の MVC アプリへの追加 そのまま足せる MVC でも Flux を返せる

WebFlux の基本は、関連記事「Spring WebFlux入門|Mono・Fluxの基本とMVCとの違い」で扱っています。

nginx を通すと届かない・切れる(実測)

nginx の proxy_buffering は既定で on です。 1秒ごとに3件送り、最後に閉じるサーバーを nginx 1.24.0 の後ろに置いて、届いた時刻を測りました。

nginx の設定・応答ヘッダー 1件目 2件目 3件目
既定(proxy_buffering on) 3.02秒 3.02秒 3.02秒
既定+アプリが X-Accel-Buffering: no を返す 1.00秒 2.01秒 3.01秒
proxy_buffering off 1.01秒 2.01秒 3.02秒

既定の設定では、3件とも応答が終わった 3.02 秒後にまとめて届きました。 応答が終わらない SSE では、バッファ(既定 4KB か 8KB)がいっぱいになるまで届きません。アプリから X-Accel-Buffering: no を返すと、その応答だけバッファリングが止まります(このヘッダーは nginx が受け取って消し、クライアントには届きませんでした)。

Spring 側でヘッダーを付ける例です。

@GetMapping(path = "/notices", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<SseEmitter> subscribe(
        @RequestHeader(name = "Last-Event-ID", required = false) String lastEventId) {
    return ResponseEntity.ok()
            .header("X-Accel-Buffering", "no")
            .header("Cache-Control", "no-cache")
            .body(hub.subscribe(lastEventId));
}

proxy_read_timeout とハートビート

proxy_read_timeout(既定 60 秒)は、上流(アプリ)から何も届かない時間の上限です。3秒に縮めて試しました。

状況 結果
応答ヘッダーも最初のイベントも出さずに5秒待つ 504 Gateway Time-out(upstream timed out … while reading response header)
ヘッダーを返したあと、次のイベントまで5秒空く 3.01秒で接続が閉じられた(while reading upstream)
同じく5秒空くが、1秒ごとに :keepalive を送る 5.01秒・10.01秒に2件とも届いた

最初のイベントを送るまで何も返さないと、504 になります。 上のコードで、接続直後に comment("connected") を送っているのはこのためです。

SSE 用の location の例です。

location /notices {
    proxy_pass http://app:8080;
    proxy_http_version 1.1;          # 1.29.7 より前の nginx は既定が 1.0
    proxy_set_header Connection "";
    proxy_buffering off;             # アプリ側の X-Accel-Buffering: no でもよい
    proxy_cache off;
    proxy_read_timeout 1h;           # ハートビートの間隔より長く
}

gzip on で text/event-stream も圧縮する設定にしても、proxy_buffering off のもとでは1秒ごとに届きました(nginx 1.24.0 で、届いた分を展開して確認)。nginx のリバースプロキシ全般は、関連記事「Nginxリバースプロキシ設定|proxy_passの書き方と502/504対処」で扱っています。

よくある質問

Q. WebSocket との使い分けは? A. SSE はサーバーからの一方向で、ふつうの HTTP なので、認証・プロキシ・ログの仕組みをそのまま使えます。双方向に頻繁にやり取りするなら WebSocket です。関連記事「Spring BootのWebSocket|STOMPでチャットを作る完成コード」で扱っています。

Q. 同じブラウザでタブをたくさん開くと、つながらなくなります。 A. HTTP/1.1 では、ブラウザが同じサーバーへの同時接続数を制限していて、SSE の接続がその枠を使い続けます。HTML の仕様にもこの注意があります。HTTP/2 で配信すると、1本の接続の中で複数の SSE を流せます。

Q. ヘッダーで認証トークンを送りたい。 A. ブラウザの EventSource は、独自のヘッダーを付けられません。Cookie のセッションを使うか、fetch でストリームを読む実装にします。

Q. LLM の回答を少しずつ返したい。 A. Spring AI の ChatClient の stream() は Flux<String> を返すので、produces = MediaType.TEXT_EVENT_STREAM_VALUE を付けてそのまま返せます。関連記事「Spring AI入門|ChatClientの使い方とコード全文(2.0対応)」で扱っています。

まとめ

  • MVC は SseEmitter、WebFlux は Flux<ServerSentEvent<T>>
  • 引数なしの SseEmitter は Tomcat で 30 秒。new SseEmitter(ミリ秒) か spring.mvc.async.request-timeout
  • 切断は送信の失敗でしか分からない。15秒ごとのコメント行で検出し、集合から外す
  • ブラウザは 自動で再接続し Last-Event-ID を送る(retry なしで約3秒)。204 で再接続を止められる
  • nginx は X-Accel-Buffering: no か proxy_buffering off。最初のイベントは接続直後に送る

参考・出典

確認日はいずれも 2026年10月13日です。

  • Spring Framework Reference「Asynchronous Requests」(SSE・Disconnects):https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html
  • Spring Framework ソース(タグ v7.0.9、SseEmitter・ResponseBodyEmitter):https://github.com/spring-projects/spring-framework/tree/v7.0.9/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation
  • HTML Living Standard「Server-sent events」:https://html.spec.whatwg.org/multipage/server-sent-events.html
  • nginx「ngx_http_proxy_module」(proxy_buffering・proxy_read_timeout・X-Accel-Buffering):https://nginx.org/en/docs/http/ngx_http_proxy_module.html
  • Apache Tomcat 11「The HTTP Connector」(asyncTimeout):https://tomcat.apache.org/tomcat-11.0-doc/config/http.html
  • Spring Boot Reference「Common Application Properties」(spring.mvc.async.request-timeout):https://docs.spring.io/spring-boot/appendix/application-properties/index.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring Boot SSE|SseEmitterの使い方と再接続・nginx設定」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

成長のヒミツ、 ぜんぶ丸わかり