diff --git a/pygittools/merge.py b/pygittools/merge.py
index a182784..c0da589 100644
--- a/pygittools/merge.py
+++ b/pygittools/merge.py
@@ -151,7 +151,9 @@ class MergeRequest:
 		return oid
 
 	def refresh_from_theirs(self, repo: Repository) -> Oid | None:
-		"""If ``theirs`` is set, move tag target to that ref's peeled commit tip when it differs. Returns new tag OID if rewritten."""
+		"""If ``theirs`` is set, move tag target to that ref's peeled commit tip when it differs.
+		Returns new tag OID if rewritten.
+		"""
 		if not self.theirs:
 			return None
 		try:
diff --git a/pygitweb/README.md b/pygitweb/README.md
index ea39442..1d859a8 100644
--- a/pygitweb/README.md
+++ b/pygitweb/README.md
@@ -107,7 +107,7 @@ Load the `/docs` page for a detailed view of routes (below the readme) if in deb
 - `by_tag` → ctag
 - `ds` → diff_style
 - `pf` → project_filter
-- `updates` → long-poll subscription flag (`true` parks the request up to 30s; returns `304 Not Modified` on timeout, or the action data when the queue is notified)
+- `updates` → long-poll subscription flag (`true` parks the request up to 30s; returns `200 OK` with an empty body on timeout/shutdown, or the action data when the queue is notified)
 
 ## Live updates (long polling)
 
@@ -116,7 +116,7 @@ subscribe to the project's change queue. The request is held for up to 30 second
 
 - A `POST /_internal/notify?project=<name>` (typically from a server-side git hook) wakes
   matching subscribers, who then receive the freshly-rendered action response.
-- If no notification arrives in 30 seconds, the response is `304 Not Modified` (empty body).
+- If no notification arrives in 30 seconds (or the server is shutting down), the response is `200 OK` with an empty body.
 - Combine with `pf=<prefix>` to subscribe to every project under a path prefix instead of
   just the URL project (the action is still rendered for the URL project).
 
diff --git a/pygitweb/change_queue.py b/pygitweb/change_queue.py
index a9129a2..ebb1754 100644
--- a/pygitweb/change_queue.py
+++ b/pygitweb/change_queue.py
@@ -25,22 +25,62 @@ class ChangeQueue:
 		self,
 		projects: list[str],
 		timeout: float = DEFAULT_TIMEOUT_SECONDS,
+		shutdown_event: asyncio.Event | None = None,
 	) -> str | None:
-		"""Park until any of `projects` is notified, or `timeout` seconds elapse.
+		"""Park until any of `projects` is notified, `shutdown_event` is set, or `timeout` elapses.
 
-		Returns the notified project name, or None on timeout.
+		Returns the notified project name, or None on timeout/shutdown.
 		"""
+		if shutdown_event and shutdown_event.is_set():
+			return None
+
 		if not projects:
+			if shutdown_event:
+				t_sleep: asyncio.Task[None] = asyncio.create_task(asyncio.sleep(timeout))
+				t_sd: asyncio.Task[None] = asyncio.create_task(shutdown_event.wait())
+				try:
+					done, pending = await asyncio.wait(
+						{t_sleep, t_sd},
+						timeout=timeout,
+						return_when=asyncio.FIRST_COMPLETED,
+					)
+				finally:
+					for t in pending:
+						t.cancel()
+					await asyncio.gather(t_sleep, t_sd, return_exceptions=True)
+				return None
 			await asyncio.sleep(timeout)
 			return None
+
 		event: asyncio.Event = asyncio.Event()
 		async with self._lock:
 			for project in projects:
 				self._waiters.setdefault(project, []).append(event)
 		try:
-			await asyncio.wait_for(event.wait(), timeout=timeout)
-		except TimeoutError:
-			return None
+			if shutdown_event:
+				t_notify: asyncio.Task[None] = asyncio.create_task(event.wait())
+				t_shutdown: asyncio.Task[None] = asyncio.create_task(shutdown_event.wait())
+				try:
+					done, pending = await asyncio.wait(
+						{t_notify, t_shutdown},
+						timeout=timeout,
+						return_when=asyncio.FIRST_COMPLETED,
+					)
+				finally:
+					for t in pending:
+						t.cancel()
+					await asyncio.gather(t_notify, t_shutdown, return_exceptions=True)
+				if not done:
+					return None
+				if t_shutdown in done:
+					return None
+				return getattr(event, "_change_queue_project", None)
+
+			try:
+				await asyncio.wait_for(event.wait(), timeout=timeout)
+			except TimeoutError:
+				return None
+			return getattr(event, "_change_queue_project", None)
 		finally:
 			async with self._lock:
 				for project in projects:
@@ -49,7 +89,6 @@ class ChangeQueue:
 						bucket.remove(event)
 					if not bucket:
 						self._waiters.pop(project, None)
-		return getattr(event, "_change_queue_project", None)
 
 	async def notify(self, project: str) -> int:
 		"""Wake all waiters subscribed to `project`. Returns the number of waiters woken."""
diff --git a/pygitweb/change_queue_routes_test.py b/pygitweb/change_queue_routes_test.py
index 5091e93..5284c08 100644
--- a/pygitweb/change_queue_routes_test.py
+++ b/pygitweb/change_queue_routes_test.py
@@ -52,17 +52,36 @@ def _client() -> httpx.AsyncClient:
 
 
 class TestUpdatesLongPoll:
-	def test_updates_returns_304_after_timeout(self, updates_env: dict[str, str]) -> None:
-		async def scenario() -> int:
+	def test_updates_returns_200_empty_after_timeout(self, updates_env: dict[str, str]) -> None:
+		async def scenario() -> tuple[int, bytes]:
 			async with _client() as client:
 				with patch.object(CHANGE_QUEUE, "DEFAULT_TIMEOUT_SECONDS", 0.05):
 					resp = await client.get(
 						f"/project/{updates_env['alpha']}",
 						params={"a": "log", "updates": "true"},
 					)
-				return resp.status_code
+				return resp.status_code, resp.content
 
-		assert asyncio.run(scenario()) == 304
+		status, body = asyncio.run(scenario())
+		assert status == 200
+		assert body == b""
+
+	def test_updates_returns_200_empty_when_shutting_down(self, updates_env: dict[str, str]) -> None:
+		async def scenario() -> tuple[int, bytes]:
+			async with _client() as client:
+				app.state.shutting_down = True
+				try:
+					resp = await client.get(
+						f"/project/{updates_env['alpha']}",
+						params={"a": "log", "updates": "true"},
+					)
+					return resp.status_code, resp.content
+				finally:
+					app.state.shutting_down = False
+
+		status, body = asyncio.run(scenario())
+		assert status == 200
+		assert body == b""
 
 	def test_updates_returns_data_after_notify(self, updates_env: dict[str, str]) -> None:
 		async def scenario() -> tuple[int, str]:
diff --git a/pygitweb/change_queue_test.py b/pygitweb/change_queue_test.py
index 7cfed6e..3648c58 100644
--- a/pygitweb/change_queue_test.py
+++ b/pygitweb/change_queue_test.py
@@ -67,3 +67,25 @@ class TestChangeQueue:
 			return await queue.notify("alpha")
 
 		assert asyncio.run(scenario()) == 0
+
+	def test_wait_returns_none_when_shutdown_event_set(self) -> None:
+		async def scenario() -> str | None:
+			queue: ChangeQueue = ChangeQueue()
+			sd: asyncio.Event = asyncio.Event()
+			sd.set()
+			return await queue.wait_for_changes(["alpha"], timeout=2.0, shutdown_event=sd)
+
+		assert asyncio.run(scenario()) is None
+
+	def test_wait_returns_none_when_shutdown_beats_notify(self) -> None:
+		async def scenario() -> str | None:
+			queue: ChangeQueue = ChangeQueue()
+			sd: asyncio.Event = asyncio.Event()
+			waiter: asyncio.Task[str | None] = asyncio.create_task(
+				queue.wait_for_changes(["alpha"], timeout=2.0, shutdown_event=sd)
+			)
+			await asyncio.sleep(0)
+			sd.set()
+			return await waiter
+
+		assert asyncio.run(scenario()) is None
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 7520176..c78e97b 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -5,7 +5,11 @@ Ported from gitweb/gitweb.perl dispatch and action handlers.
 
 from __future__ import annotations
 
+import asyncio
 import os
+import signal
+import sys
+from collections.abc import AsyncGenerator, Callable
 from contextlib import asynccontextmanager
 from datetime import UTC, datetime
 from pathlib import Path
@@ -112,11 +116,50 @@ def _resolve_subscribed_projects(project: str, project_filter: str | None) -> li
 	return names or [project]
 
 
+def _updates_idle_response() -> Response:
+	return Response(status_code=200, content=b"")
+
+
+def _install_graceful_shutdown_wakeup(app: FastAPI) -> None:
+	"""Wake ``updates=true`` waiters as soon as shutdown is requested.
+
+	Uvicorn waits for open connections to finish before running ASGI lifespan shutdown,
+	so toggling ``shutdown_event`` only from lifespan would deadlock with long polls.
+	We chain OS signals (same ones uvicorn uses) and notify waiters before uvicorn's
+	handler runs.
+	"""
+	signals: tuple[int, ...] = (signal.SIGINT, signal.SIGTERM)
+	if sys.platform == "win32":
+		signals = signals + (signal.SIGBREAK,)
+	for sig in signals:
+
+		def make_chain(
+			previous: Callable[[int, object | None], object] | int | None,
+		) -> Callable[[int, object | None], None]:
+			def handler(signum: int, frame: object | None) -> None:
+				app.state.shutting_down = True
+				app.state.shutdown_event.set()
+				if callable(previous) and previous not in (signal.SIG_DFL, signal.SIG_IGN):
+					previous(signum, frame)
+
+			return handler
+
+		prev = signal.getsignal(sig)
+		signal.signal(sig, make_chain(prev))
+
+
 @asynccontextmanager
-async def timeline_cache_lifespan(app: FastAPI) -> None:
+async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
+	app.state.shutting_down = False
+	app.state.shutdown_event = asyncio.Event()
+	_install_graceful_shutdown_wakeup(app)
 	warm_timeline_cache_async()
-	yield
-	clear_timeline_cache_async()
+	try:
+		yield
+	finally:
+		app.state.shutting_down = True
+		app.state.shutdown_event.set()
+		clear_timeline_cache_async()
 
 
 with open("pygitweb/README.md", encoding="utf-8") as _readme_file:
@@ -128,7 +171,7 @@ app = FastAPI(
 	summary="FastAPI + Pygit2 Repo Browser",
 	description=_app_description,
 	version=__meta__.__version__,
-	lifespan=timeline_cache_lifespan,
+	lifespan=app_lifespan,
 )
 
 PLUGIN_ACTIONS = load_plugin_actions()
@@ -795,9 +838,16 @@ async def dispatch(
 		if action not in UPDATES_SUPPORTED_ACTIONS:
 			raise HTTPException(status_code=400, detail="updates not supported for this action")
 		subscribed = _resolve_subscribed_projects(project, pf)
-		notified = await CHANGE_QUEUE.wait_for_changes(subscribed, timeout=CHANGE_QUEUE.DEFAULT_TIMEOUT_SECONDS)
+		if getattr(request.app.state, "shutting_down", False):
+			return _updates_idle_response()
+		shutdown_ev: asyncio.Event | None = getattr(request.app.state, "shutdown_event", None)
+		notified = await CHANGE_QUEUE.wait_for_changes(
+			subscribed,
+			timeout=CHANGE_QUEUE.DEFAULT_TIMEOUT_SECONDS,
+			shutdown_event=shutdown_ev,
+		)
 		if notified is None:
-			return Response(status_code=304)
+			return _updates_idle_response()
 	if action in PLUGIN_ACTIONS:
 		plugin = PLUGIN_ACTIONS[action]
 		result = plugin.action(project, request)
diff --git a/pygitweb/merge_requests.py b/pygitweb/merge_requests.py
index 8341da0..28568ef 100644
--- a/pygitweb/merge_requests.py
+++ b/pygitweb/merge_requests.py
@@ -21,6 +21,7 @@ from pygitweb.validation import is_valid_ref_format
 def _branch_short_name(ref: str) -> str:
 	return ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else ref
 
+
 merge_router = APIRouter(tags=["merge_requests"])
 
 _STATUS_LABELS: dict[MergeRequestStatus, str] = {
@@ -39,7 +40,9 @@ def try_merge_request_from_tag(repo: pygit2.Repository, tag: pygit2.Tag) -> Merg
 		return None
 
 
-def resolve_merge_request_tag_to_tip(repo: pygit2.Repository, obj: pygit2.Tag) -> tuple[pygit2.Tag, MergeRequest] | None:
+def resolve_merge_request_tag_to_tip(
+	repo: pygit2.Repository, obj: pygit2.Tag
+) -> tuple[pygit2.Tag, MergeRequest] | None:
 	"""If ``obj`` is an MR tag and ``refs/tags/mr/…`` points to a newer tag object, return that tip."""
 	mr = try_merge_request_from_tag(repo, obj)
 	if mr is None:
diff --git a/pygitweb/static/summary-ref-switcher.js b/pygitweb/static/summary-ref-switcher.js
index 2f4ad5e..e2ced67 100644
--- a/pygitweb/static/summary-ref-switcher.js
+++ b/pygitweb/static/summary-ref-switcher.js
@@ -106,8 +106,11 @@
 				return;
 			}
 			if (resp.status === 200) {
-				const effectiveRef = await loadOptions();
-				await loadState(effectiveRef ?? selectEl.value);
+				const text = await resp.text();
+				if (text.length > 0) {
+					const effectiveRef = await loadOptions();
+					await loadState(effectiveRef ?? selectEl.value);
+				}
 			} else if (resp.status !== 304) {
 				await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_BACKOFF_MS));
 			}
