diff --git a/docs/web.rst b/docs/web.rst
index 956336bda..00066ccdf 100644
--- a/docs/web.rst
+++ b/docs/web.rst
@@ -224,14 +224,22 @@
            of `UIModule` or UI methods to be made available to templates.
            May be set to a module, dictionary, or a list of modules
            and/or dicts.  See :ref:`ui-modules` for more details.
-         * ``websocket_ping_interval``: If set to a number, all websockets will
-           be pinged every n seconds. This can help keep the connection alive
-           through certain proxy servers which close idle connections, and it
-           can detect if the websocket has failed without being properly closed.
-         * ``websocket_ping_timeout``: If the ping interval is set, and the
-           server doesn't receive a 'pong' in this many seconds, it will close
-           the websocket. The default is three times the ping interval, with a
-           minimum of 30 seconds. Ignored if the ping interval is not set.
+         * ``websocket_ping_interval``: If the ping interval has a non-zero
+           value, a ping will be sent periodically every
+           ``websocket_ping_interval`` seconds, and the connection will be
+           closed if a response is not received before the
+           ``websocket_ping_timeout``.
+           This can help keep the connection alive through certain proxy
+           servers which close idle connections, and it can detect if the
+           websocket has failed without being properly closed.
+         * ``websocket_ping_timeout``: For use with ``websocket_ping_interval``,
+           if the server does not receive a pong within this many seconds, it
+           will close the websocket_ping_timeout.
+           The default timeout is equal to the ping interval. The ping timeout
+           will be turned off if the ping interval is not set or if the
+           timeout is set to ``0``.
+           This can help to detect disconnected clients to avoid keeping
+           inactive connections open.
 
          Authentication and security settings:
 
diff --git a/tornado/test/websocket_test.py b/tornado/test/websocket_test.py
index 1f317fb0d..494c4bf67 100644
--- a/tornado/test/websocket_test.py
+++ b/tornado/test/websocket_test.py
@@ -1,5 +1,6 @@
 import asyncio
 import contextlib
+import datetime
 import functools
 import socket
 import traceback
@@ -861,16 +862,21 @@ def initialize(self, close_future=None, compression_options=None):
         return app
 
     @staticmethod
-    def suppress_pong(ws):
-        """Suppress the client's "pong" response."""
+    def install_hook(ws):
+        """Optionally suppress the client's "pong" response."""
+
+        ws.drop_pongs = False
+        ws.pongs_received = 0
 
         def wrapper(fcn):
-            def _inner(oppcode: int, data: bytes):
-                if oppcode == 0xA:  # NOTE: 0x9=ping, 0xA=pong
-                    # prevent pong responses
-                    return
+            def _inner(opcode: int, data: bytes):
+                if opcode == 0xA:  # NOTE: 0x9=ping, 0xA=pong
+                    ws.pongs_received += 1
+                    if ws.drop_pongs:
+                        # prevent pong responses
+                        return
                 # leave all other responses unchanged
-                return fcn(oppcode, data)
+                return fcn(opcode, data)
 
             return _inner
 
@@ -883,13 +889,14 @@ def test_client_ping_timeout(self):
         ws = yield self.ws_connect(
             "/", ping_interval=interval, ping_timeout=interval / 4
         )
+        self.install_hook(ws)
 
         # websocket handler (server side)
         handler = self.handlers[0]
 
         for _ in range(5):
             # wait for the ping period
-            yield gen.sleep(0.2)
+            yield gen.sleep(interval)
 
             # connection should still be open from the server end
             self.assertIsNone(handler.close_code)
@@ -898,8 +905,12 @@ def test_client_ping_timeout(self):
             # connection should still be open from the client end
             assert ws.protocol.close_code is None
 
+        # Check that our hook is intercepting messages; allow for
+        # some variance in timing (due to e.g. cpu load)
+        self.assertGreaterEqual(ws.pongs_received, 4)
+
         # suppress the pong response message
-        self.suppress_pong(ws)
+        ws.drop_pongs = True
 
         # give the server time to register this
         yield gen.sleep(interval * 1.5)
@@ -912,6 +923,23 @@ def test_client_ping_timeout(self):
         self.assertEqual(ws.protocol.close_code, 1000)
 
 
+class PingCalculationTest(unittest.TestCase):
+    def test_ping_sleep_time(self):
+        from tornado.websocket import WebSocketProtocol13
+
+        now = datetime.datetime(2025, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
+        interval = 10  # seconds
+        last_ping_time = datetime.datetime(
+            2025, 1, 1, 11, 59, 54, tzinfo=datetime.timezone.utc
+        )
+        sleep_time = WebSocketProtocol13.ping_sleep_time(
+            last_ping_time=last_ping_time.timestamp(),
+            interval=interval,
+            now=now.timestamp(),
+        )
+        self.assertEqual(sleep_time, 4)
+
+
 class ManualPingTest(WebSocketBaseTestCase):
     def get_app(self):
         class PingHandler(TestWebSocketHandler):
diff --git a/tornado/websocket.py b/tornado/websocket.py
index b719547bd..c2e18d218 100644
--- a/tornado/websocket.py
+++ b/tornado/websocket.py
@@ -1346,6 +1346,11 @@ def start_pinging(self) -> None:
         ):
             self._ping_coroutine = asyncio.create_task(self.periodic_ping())
 
+    @staticmethod
+    def ping_sleep_time(*, last_ping_time: float, interval: float, now: float) -> float:
+        """Calculate the sleep time until the next ping should be sent."""
+        return max(0, last_ping_time + interval - now)
+
     async def periodic_ping(self) -> None:
         """Send a ping and wait for a pong if ping_timeout is configured.
 
@@ -1371,7 +1376,13 @@ async def periodic_ping(self) -> None:
                 return
 
             # wait until the next scheduled ping
-            await asyncio.sleep(IOLoop.current().time() - ping_time + interval)
+            await asyncio.sleep(
+                self.ping_sleep_time(
+                    last_ping_time=ping_time,
+                    interval=interval,
+                    now=IOLoop.current().time(),
+                )
+            )
 
 
 class WebSocketClientConnection(simple_httpclient._HTTPConnection):