diff --git a/pygitweb/actions.py b/pygitweb/actions.py
index e191602..88c5bcf 100644
--- a/pygitweb/actions.py
+++ b/pygitweb/actions.py
@@ -386,7 +386,7 @@ def git_summary(project: str) -> HTMLResponse:
 		body_parts.append(f"<p>{esc_html(descr)}</p>")
 	body_parts.append(table)
 	body_parts.append(f'<div id="summary-readme-container" data-project="{esc_html(project)}">')
-	body_parts.append(f'{initial_ref_state["readme_html"]}</div>')
+	body_parts.append(f"{initial_ref_state['readme_html']}</div>")
 	body_parts.append('<script src="/static/summary-ref-switcher.js"></script>')
 
 	body_parts.append(POSTAMBLE)
diff --git a/pygitweb/main.py b/pygitweb/main.py
index ad0f01e..5b91819 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -56,7 +56,7 @@ from pygitweb.tasks import (
 	task_router,
 )
 from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
-from pygitweb.timeline_cache import get_all_timeline_events, warm_timeline_cache
+from pygitweb.timeline_cache import get_all_timeline_events, warm_timeline_cache_async
 from pygitweb.validation import is_valid_action, is_valid_pathname, is_valid_project
 
 with open("pygitweb/README.md", encoding="utf-8") as _readme_file:
@@ -134,7 +134,7 @@ app.include_router(comment_router, prefix="/comments")
 
 @app.on_event("startup")
 async def warm_activity_timeline_cache() -> None:
-	warm_timeline_cache()
+	warm_timeline_cache_async()
 
 
 @app.middleware("http")
@@ -283,7 +283,7 @@ def activity_page(
 			event_label = f"<a href='/project/{quote(project_name, safe='/')}"
 			event_label += f"?a=commit&h={quote(oid, safe='')}'>{esc_html(oid[:7])}</a>"
 		else:
-			event_label = esc_html(event["kind"])
+			event_label = esc_html(event["kind"]) or ""
 		description = _truncate_activity_description(event.get("description"))
 		rows.append([
 			esc_html(_format_activity_time(event["timestamp"])) or "",
diff --git a/pygitweb/timeline_cache.py b/pygitweb/timeline_cache.py
index 854ecc3..2547a6a 100644
--- a/pygitweb/timeline_cache.py
+++ b/pygitweb/timeline_cache.py
@@ -4,6 +4,7 @@ Per-project timeline cache for recent repository activity.
 
 from __future__ import annotations
 
+import threading
 from typing import Literal, TypedDict
 
 import pygit2
@@ -13,6 +14,8 @@ from pygitweb.git_helpers import open_repo
 from pygitweb.projects import git_get_projects_list
 
 TIMELINE_CACHE_SIZE: int = 10
+TIMELINE_CACHE_TOTAL_EVENTS: int = 200
+TIMELINE_CACHE_BATCH_SIZE: int = 5
 
 
 class TimelineEvent(TypedDict):
@@ -28,6 +31,8 @@ class TimelineProjectEvent(TypedDict):
 
 
 PROJECT_TIMELINE_CACHE: dict[str, list[TimelineEvent]] = {}
+_TIMELINE_CACHE_LOCK = threading.Lock()
+_TIMELINE_WARMING: bool = False
 
 
 def _commit_description(commit: pygit2.Commit) -> str | None:
@@ -38,13 +43,13 @@ def _commit_description(commit: pygit2.Commit) -> str | None:
 
 
 def _collect_commit_events(repo: pygit2.Repository) -> list[TimelineEvent]:
-	start_points: list[pygit2.Oid] = []
+	start_points: list[pygit2.Oid | str] = []
 	for ref_name in repo.references:
 		if not ref_name.startswith("refs/heads/"):
 			continue
 		try:
 			resolved: pygit2.Reference = repo.references[ref_name].resolve()
-			oid: pygit2.Oid = resolved.target
+			oid: pygit2.Oid | str = resolved.target
 			start_points.append(oid)
 		except (KeyError, pygit2.GitError, ValueError):
 			continue
@@ -59,7 +64,7 @@ def _collect_commit_events(repo: pygit2.Repository) -> list[TimelineEvent]:
 
 	walker: pygit2.Walker = repo.walk(
 		start_points[0],
-		pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL,
+		pygit2.enums.SortMode(pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL),
 	)
 	for oid in start_points[1:]:
 		try:
@@ -128,31 +133,100 @@ def build_project_timeline_cache(project: str, max_events: int = TIMELINE_CACHE_
 
 
 def warm_timeline_cache() -> None:
-	global PROJECT_TIMELINE_CACHE
-	new_cache: dict[str, list[TimelineEvent]] = {}
-	projects = git_get_projects_list(
-		filter_path="",
-		paranoid=settings.STRICT_EXPORT,
-		export_ok=settings.EXPORT_OK,
-	)
-	for project_entry in projects:
-		project_name = project_entry.get("path")
-		if not isinstance(project_name, str) or not project_name:
-			continue
-		try:
-			new_cache[project_name] = build_project_timeline_cache(project_name)
-		except (pygit2.GitError, OSError, ValueError, KeyError):
+	global PROJECT_TIMELINE_CACHE, _TIMELINE_WARMING
+	with _TIMELINE_CACHE_LOCK:
+		_TIMELINE_WARMING = True
+	try:
+		new_cache: dict[str, list[TimelineEvent]] = {}
+		projects = git_get_projects_list(
+			filter_path="",
+			paranoid=settings.STRICT_EXPORT,
+			export_ok=settings.EXPORT_OK,
+		)
+		project_names: list[str] = []
+		project_event_pool: dict[str, list[TimelineEvent]] = {}
+		for project_entry in projects:
+			project_name = project_entry.get("path")
+			if not isinstance(project_name, str) or not project_name:
+				continue
+			project_names.append(project_name)
+			try:
+				project_event_pool[project_name] = build_project_timeline_cache(
+					project_name,
+					max_events=TIMELINE_CACHE_TOTAL_EVENTS,
+				)
+			except (pygit2.GitError, OSError, ValueError, KeyError):
+				project_event_pool[project_name] = []
+
+		for project_name in project_names:
 			new_cache[project_name] = []
-	PROJECT_TIMELINE_CACHE = new_cache
-
 
-def get_project_timeline_cache(project: str) -> list[TimelineEvent]:
-	return PROJECT_TIMELINE_CACHE.get(project, [])
+		selected_events: list[TimelineProjectEvent] = []
+		next_index_by_project: dict[str, int] = {project_name: 0 for project_name in project_names}
+
+		for project_name in project_names:
+			if len(selected_events) >= TIMELINE_CACHE_TOTAL_EVENTS:
+				break
+			project_events = project_event_pool.get(project_name, [])
+			if not project_events:
+				continue
+			selected_events.append({"project": project_name, "event": project_events[0]})
+			next_index_by_project[project_name] = 1
+
+		while len(selected_events) < TIMELINE_CACHE_TOTAL_EVENTS:
+			candidate_projects: list[str] = [
+				project_name
+				for project_name in project_names
+				if next_index_by_project[project_name] < len(project_event_pool.get(project_name, []))
+			]
+			if not candidate_projects:
+				break
+			best_project: str = max(
+				candidate_projects,
+				key=lambda project_name: (
+					project_event_pool[project_name][next_index_by_project[project_name]]["timestamp"],
+					project_event_pool[project_name][next_index_by_project[project_name]]["oid"],
+					project_name,
+				),
+			)
+			start_index = next_index_by_project[best_project]
+			remaining_global = TIMELINE_CACHE_TOTAL_EVENTS - len(selected_events)
+			remaining_project = len(project_event_pool[best_project]) - start_index
+			take_count = min(TIMELINE_CACHE_BATCH_SIZE, remaining_global, remaining_project)
+			for offset in range(take_count):
+				selected_events.append({
+					"project": best_project,
+					"event": project_event_pool[best_project][start_index + offset],
+				})
+			next_index_by_project[best_project] = start_index + take_count
+
+		for item in selected_events:
+			new_cache[item["project"]].append(item["event"])
+		with _TIMELINE_CACHE_LOCK:
+			PROJECT_TIMELINE_CACHE = new_cache
+	finally:
+		with _TIMELINE_CACHE_LOCK:
+			_TIMELINE_WARMING = False
+
+
+def warm_timeline_cache_async() -> bool:
+	global _TIMELINE_WARMING
+	with _TIMELINE_CACHE_LOCK:
+		if _TIMELINE_WARMING:
+			return False
+		_TIMELINE_WARMING = True
+	thread = threading.Thread(target=warm_timeline_cache, daemon=True)
+	thread.start()
+	return True
 
 
 def get_all_timeline_events() -> list[TimelineProjectEvent]:
 	events: list[TimelineProjectEvent] = []
-	for project_name, project_events in PROJECT_TIMELINE_CACHE.items():
+	with _TIMELINE_CACHE_LOCK:
+		cache_snapshot = {
+			project_name: list(project_events) for project_name, project_events in PROJECT_TIMELINE_CACHE.items()
+		}
+	for project_name, project_events in cache_snapshot.items():
 		for event in project_events:
 			events.append({"project": project_name, "event": event})
 	events.sort(
