diff --git a/src/framework/Common.cpp b/src/framework/Common.cpp
--- a/src/framework/Common.cpp
+++ b/src/framework/Common.cpp
@@ -228,6 +228,13 @@ static int Common_GetRequestedPresentationCap( void ) {
 }
 
 static void Common_ThrottlePresentationFrame( void ) {
+#if defined( __EMSCRIPTEN__ )
+	// requestAnimationFrame owns browser presentation pacing. Sleeping inside its
+	// callback can suspend the only thread that is able to schedule the wakeup.
+	Common_ResetPresentationThrottle();
+	return;
+#endif
+
 	const int cap = Common_GetRequestedPresentationCap();
 
 	if ( cap <= 0 ) {
diff --git a/src/framework/Session.cpp b/src/framework/Session.cpp
--- a/src/framework/Session.cpp
+++ b/src/framework/Session.cpp
@@ -99,14 +99,17 @@ static int Session_FindPresentationCap( void ) {
 }
 
 static float Session_GetBlockingLoadFrameIntervalMsec( void ) {
+	// Blocking asset reads can invoke PacifierUpdate for every byte. Rendering the
+	// loading GUI faster than 10 Hz makes software and browser map loads I/O-bound.
+	static const float minimumBlockingLoadFrameIntervalMsec = 100.0f;
 	const int presentationCap = Session_FindPresentationCap();
 	if ( presentationCap > 0 ) {
-		return 1000.0f / static_cast<float>( presentationCap );
+		return Max( minimumBlockingLoadFrameIntervalMsec, 1000.0f / static_cast<float>( presentationCap ) );
 	}
 
 	// Uncapped blocking load hooks can fire extremely often during asset I/O.
 	// Keep the legacy one-tic pacing as the conservative fallback in that mode.
-	return common->GetUserCmdMsecFloat();
+	return Max( minimumBlockingLoadFrameIntervalMsec, common->GetUserCmdMsecFloat() );
 }
 
 static void Session_BeginBlockingLoadPresentationFrame( void ) {
@@ -113,6 +116,10 @@ static void Session_BeginBlockingLoadPresentationFrame( void ) {
+#ifdef __EMSCRIPTEN__
+	// Browser builds have no independent async thread while a map load blocks.
+	common->Async();
+#endif
 	if ( Session_FindPresentationCap() > 0 ) {
 		openQ4_BeginPresentationFrame();
 	} else {
 		com_frameRealTime = Sys_Milliseconds();
 	}
 }
diff --git a/src/renderer/ImageManager.cpp b/src/renderer/ImageManager.cpp
--- a/src/renderer/ImageManager.cpp
+++ b/src/renderer/ImageManager.cpp
@@ -950,8 +950,7 @@ int idImageManager::LoadLevelImages( bool pacifier ) {
 	int	loadCount = 0;
 	for ( int i = 0 ; i < images.Num() ; i++ ) {
 		if ( pacifier ) {
-			//common->UpdateLevelLoadPacifier();
-
+			session->PacifierUpdate();
 		}
 
 		idImage	*image = images[ i ];
diff --git a/src/renderer/RenderSystem_init.cpp b/src/renderer/RenderSystem_init.cpp
--- a/src/renderer/RenderSystem_init.cpp
+++ b/src/renderer/RenderSystem_init.cpp
@@ -397,6 +397,12 @@ static bool r_loadingScreenSwapIntervalBypass = false;
 
 static int R_GetEffectiveSwapIntervalForState( bool loadingScreenBypassActive ) {
 	const int configuredInterval = r_swapInterval.GetInteger();
+#if defined( __EMSCRIPTEN__ )
+	// SDL maps interval zero to a setTimeout main loop in Emscripten. Keep the
+	// requestAnimationFrame scheduler throughout blocking browser map loads.
+	return configuredInterval;
+#endif
+
 	if ( loadingScreenBypassActive && r_disableVSyncDuringLevelLoad.GetBool() && configuredInterval != 0 ) {
 		return 0;
 	}
