diff --git a/SDL/SDLMain.cpp b/SDL/SDLMain.cpp
index 2ec6b16be3e8..a5d44460fe52 100644
--- a/SDL/SDLMain.cpp
+++ b/SDL/SDLMain.cpp
@@ -708,31 +708,6 @@ static void EmuThreadJoin() {
 }
 
 struct InputStateTracker {
-	void TranslateMouseWheel() {
-		// SDL2 doesn't consider the mousewheel a button anymore
-		// so let's send the KEY_UP if it was moved after some frames
-		if (mouseWheelMovedUpFrames > 0) {
-			mouseWheelMovedUpFrames--;
-			if (mouseWheelMovedUpFrames == 0) {
-				KeyInput key;
-				key.deviceId = DEVICE_ID_MOUSE;
-				key.keyCode = NKCODE_EXT_MOUSEWHEEL_UP;
-				key.flags = KEY_UP;
-				NativeKey(key);
-			}
-		}
-		if (mouseWheelMovedDownFrames > 0) {
-			mouseWheelMovedDownFrames--;
-			if (mouseWheelMovedDownFrames == 0) {
-				KeyInput key;
-				key.deviceId = DEVICE_ID_MOUSE;
-				key.keyCode = NKCODE_EXT_MOUSEWHEEL_DOWN;
-				key.flags = KEY_UP;
-				NativeKey(key);
-			}
-		}
-	}
-
 	void MouseCaptureControl() {
 		bool captureMouseCondition = g_Config.bMouseControl && ((GetUIState() == UISTATE_INGAME && g_Config.bMouseConfine) || g_Config.bMapMouse);
 		if (mouseCaptured != captureMouseCondition) {
@@ -745,8 +720,6 @@ struct InputStateTracker {
 	}
 
 	bool mouseDown;
-	int mouseWheelMovedUpFrames;
-	int mouseWheelMovedDownFrames;
 	bool mouseCaptured;
 };
 
@@ -1019,11 +992,9 @@ static void ProcessSDLEvent(SDL_Window *window, const SDL_Event &event, InputSta
 #endif
 			if (event.wheel.y > 0) {
 				key.keyCode = NKCODE_EXT_MOUSEWHEEL_UP;
-				inputTracker->mouseWheelMovedUpFrames = 5;
 				NativeKey(key);
 			} else if (event.wheel.y < 0) {
 				key.keyCode = NKCODE_EXT_MOUSEWHEEL_DOWN;
-				inputTracker->mouseWheelMovedDownFrames = 5;
 				NativeKey(key);
 			}
 			break;
@@ -1448,8 +1419,6 @@ int main(int argc, char *argv[]) {
 	if (!mainThreadIsRender) {
 		// We should only be a message pump
 		while (true) {
-			inputTracker.TranslateMouseWheel();
-
 			SDL_Event event;
 			while (SDL_PollEvent(&event)) {
 				ProcessSDLEvent(window, event, &inputTracker);
@@ -1461,7 +1430,6 @@ int main(int argc, char *argv[]) {
 
 			inputTracker.MouseCaptureControl();
 
-
 			{
 				std::lock_guard<std::mutex> guard(g_mutexWindow);
 				if (g_windowState.update) {
@@ -1470,8 +1438,6 @@ int main(int argc, char *argv[]) {
 			}
 		}
 	} else while (true) {
-		inputTracker.TranslateMouseWheel();
-
 		{
 			SDL_Event event;
 			while (SDL_PollEvent(&event)) {
diff --git a/UI/NativeApp.cpp b/UI/NativeApp.cpp
index 3f22acd0af9b..6d9ca17bf8ea 100644
--- a/UI/NativeApp.cpp
+++ b/UI/NativeApp.cpp
@@ -159,6 +159,7 @@
 #include <Core/HLE/Plugins.h>
 
 bool HandleGlobalMessage(UIMessage message, const std::string &value);
+static void ProcessWheelRelease(InputKeyCode keyCode, double now, bool keyPress);
 
 ScreenManager *g_screenManager;
 std::string config_filename;
@@ -1040,6 +1041,9 @@ void NativeFrame(GraphicsContext *graphicsContext) {
 
 	double startTime = time_now_d();
 
+	ProcessWheelRelease(NKCODE_EXT_MOUSEWHEEL_UP, startTime, false);
+	ProcessWheelRelease(NKCODE_EXT_MOUSEWHEEL_DOWN, startTime, false);
+
 	std::vector<PendingMessage> toProcess;
 	{
 		std::lock_guard<std::mutex> lock(pendingMutex);
@@ -1262,7 +1266,29 @@ void NativeTouch(const TouchInput &touch) {
 	g_screenManager->touch(touch);
 }
 
+// up, down
+static double g_wheelReleaseTime[2]{};
+static const double RELEASE_TIME = 0.1;  // about 3 frames at 30hz.
+
+static void ProcessWheelRelease(InputKeyCode keyCode, double now, bool keyPress) {
+	int dir = keyCode - NKCODE_EXT_MOUSEWHEEL_UP;
+	if (g_wheelReleaseTime[dir] != 0.0 && (keyPress || now >= g_wheelReleaseTime[dir])) {
+		g_wheelReleaseTime[dir] = 0.0;
+		KeyInput key{};
+		key.deviceId = DEVICE_ID_MOUSE;
+		key.keyCode = keyCode;
+		key.flags = KEY_UP;
+		NativeKey(key);
+	}
+
+	if (keyPress) {
+		g_wheelReleaseTime[dir] = now + RELEASE_TIME;
+	}
+}
+
 bool NativeKey(const KeyInput &key) {
+	double now = time_now_d();
+
 	// VR actions
 	if (IsVREnabled() && !UpdateVRKeys(key)) {
 		return false;
@@ -1290,12 +1316,19 @@ bool NativeKey(const KeyInput &key) {
 	}
 #endif
 
-	bool retval = false;
-	if (g_screenManager) {
-		HLEPlugins::SetKey(key.keyCode, (key.flags & KEY_DOWN) ? 1 : 0);
-		retval = g_screenManager->key(key);
+	if (!g_screenManager) {
+		return false;
 	}
 
+	// Handle releases of mousewheel keys.
+	if ((key.flags & KEY_DOWN) && key.deviceId == DEVICE_ID_MOUSE && (key.keyCode == NKCODE_EXT_MOUSEWHEEL_UP || key.keyCode == NKCODE_EXT_MOUSEWHEEL_DOWN)) {
+		ProcessWheelRelease(key.keyCode, now, true);
+	}
+
+	HLEPlugins::SetKey(key.keyCode, (key.flags & KEY_DOWN) ? 1 : 0);
+	// Dispatch the key event.
+	bool retval = g_screenManager->key(key);
+
 	// The Mode key can have weird consequences on some devices, see #17245.
 	if (key.keyCode == NKCODE_BUTTON_MODE) {
 		// Tell the caller that we handled the key.
diff --git a/UWP/PPSSPP_UWPMain.cpp b/UWP/PPSSPP_UWPMain.cpp
index b3a9ff0a4b8d..00321de052cb 100644
--- a/UWP/PPSSPP_UWPMain.cpp
+++ b/UWP/PPSSPP_UWPMain.cpp
@@ -241,8 +241,10 @@ void PPSSPP_UWPMain::OnMouseWheel(float delta) {
 	KeyInput keyInput{};
 	keyInput.keyCode = key;
 	keyInput.deviceId = DEVICE_ID_MOUSE;
-	keyInput.flags = KEY_DOWN | KEY_UP;
+	keyInput.flags = KEY_DOWN;
 	NativeKey(keyInput);
+
+	// KEY_UP is now sent automatically afterwards for mouse wheel events, see NativeKey.
 }
 
 bool PPSSPP_UWPMain::OnHardwareButton(HardwareButton button) {
diff --git a/Windows/MainWindow.cpp b/Windows/MainWindow.cpp
index 0b7f1afe5eb0..2cdafcaccf98 100644
--- a/Windows/MainWindow.cpp
+++ b/Windows/MainWindow.cpp
@@ -112,10 +112,8 @@ static std::wstring windowTitle;
 
 #define TIMER_CURSORUPDATE 1
 #define TIMER_CURSORMOVEUPDATE 2
-#define TIMER_WHEELRELEASE 3
 #define CURSORUPDATE_INTERVAL_MS 1000
 #define CURSORUPDATE_MOVE_TIMESPAN_MS 500
-#define WHEELRELEASE_DELAY_MS 16
 
 namespace MainWindow
 {
@@ -267,17 +265,6 @@ namespace MainWindow
 		}
 	}
 
-	void ReleaseMouseWheel() {
-		// For simplicity release both wheel events
-		KeyInput key;
-		key.deviceId = DEVICE_ID_MOUSE;
-		key.flags = KEY_UP;
-		key.keyCode = NKCODE_EXT_MOUSEWHEEL_DOWN;
-		NativeKey(key);
-		key.keyCode = NKCODE_EXT_MOUSEWHEEL_UP;
-		NativeKey(key);
-	}
-
 	static void HandleSizeChange(int newSizingType) {
 		SavePosition();
 		Core_NotifyWindowHidden(false);
@@ -927,10 +914,8 @@ namespace MainWindow
 				} else {
 					key.keyCode = NKCODE_EXT_MOUSEWHEEL_UP;
 				}
-				// There's no separate keyup event for mousewheel events,
-				// so we release it with a slight delay.
+				// There's no release event, but we simulate it in NativeKey/NativeFrame.
 				key.flags = KEY_DOWN | KEY_HASWHEELDELTA | (wheelDelta << 16);
-				SetTimer(hwndMain, TIMER_WHEELRELEASE, WHEELRELEASE_DELAY_MS, 0);
 				NativeKey(key);
 			}
 			break;
@@ -946,11 +931,6 @@ namespace MainWindow
 				hideCursor = true;
 				KillTimer(hWnd, TIMER_CURSORMOVEUPDATE);
 				return 0;
-			// Hack: need to release wheel event with a delay for games to register it was "pressed down".
-			case TIMER_WHEELRELEASE:
-				ReleaseMouseWheel();
-				KillTimer(hWnd, TIMER_WHEELRELEASE);
-				return 0;
 			}
 			break;
 
@@ -1045,7 +1025,6 @@ namespace MainWindow
 		case WM_DESTROY:
 			KillTimer(hWnd, TIMER_CURSORUPDATE);
 			KillTimer(hWnd, TIMER_CURSORMOVEUPDATE);
-			KillTimer(hWnd, TIMER_WHEELRELEASE);
 			// Main window is gone, this tells the message loop to exit.
 			PostQuitMessage(0);
 			return 0;
diff --git a/android/jni/app-android.cpp b/android/jni/app-android.cpp
index 7b899d6101cc..acdf51e099a4 100644
--- a/android/jni/app-android.cpp
+++ b/android/jni/app-android.cpp
@@ -1300,8 +1300,6 @@ extern "C" jboolean Java_org_ppsspp_ppsspp_NativeApp_mouseWheelEvent(
 	// so we release it with a slight delay.
 	key.flags = KEY_DOWN | KEY_HASWHEELDELTA | (wheelDelta << 16);
 	NativeKey(key);
-	key.flags = KEY_UP;
-	NativeKey(key);
 	return true;
 }