diff --git a/src/web/hooks/__tests__/useTiming.jsx b/src/web/hooks/__tests__/useTiming.jsx
new file mode 100644
index 0000000000..28014a8440
--- /dev/null
+++ b/src/web/hooks/__tests__/useTiming.jsx
@@ -0,0 +1,141 @@
+/* SPDX-FileCopyrightText: 2024 Greenbone AG
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/* eslint-disable react/prop-types */
+
+import {useState} from 'react';
+
+import {describe, test, expect, testing} from '@gsa/testing';
+
+import {act, fireEvent, render, screen} from 'web/utils/testing';
+
+import useTiming from '../useTiming';
+
+const TestComponent = ({doFunc}) => {
+ const [value, setValue] = useState(0);
+ const timingFunc = () => {
+ setValue(value => value + 1);
+ return doFunc();
+ };
+ const [startTimer, clearTimer, isRunning] = useTiming(timingFunc, 900);
+ return (
+ <>
+
+
+ {value}
+ {'' + isRunning}
+ >
+ );
+};
+
+const runTimers = async () => {
+ await act(async () => {
+ await testing.advanceTimersToNextTimerAsync();
+ });
+};
+
+describe('useTiming', () => {
+ test('should start a timer', async () => {
+ testing.useFakeTimers();
+ const doFunc = testing.fn().mockImplementation(() => {});
+
+ render();
+
+ const value = screen.getByTestId('value');
+ const isRunning = screen.getByTestId('isRunning');
+
+ expect(value).toHaveTextContent('0');
+ expect(isRunning).toHaveTextContent('false');
+
+ fireEvent.click(screen.getByTestId('startTimer'));
+
+ expect(value).toHaveTextContent('0');
+ expect(isRunning).toHaveTextContent('true');
+
+ await runTimers();
+
+ expect(screen.getByTestId('value')).toHaveTextContent('1');
+ expect(screen.getByTestId('isRunning')).toHaveTextContent('false');
+ });
+
+ test('should keep running a timer when a promise is used', async () => {
+ testing.useFakeTimers();
+ const doFunc = testing.fn().mockResolvedValueOnce();
+
+ render();
+
+ const value = screen.getByTestId('value');
+ const isRunning = screen.getByTestId('isRunning');
+
+ expect(value).toHaveTextContent('0');
+ expect(isRunning).toHaveTextContent('false');
+
+ fireEvent.click(screen.getByTestId('startTimer'));
+
+ expect(value).toHaveTextContent('0');
+ expect(isRunning).toHaveTextContent('true');
+
+ await runTimers();
+
+ expect(screen.getByTestId('value')).toHaveTextContent('1');
+ expect(screen.getByTestId('isRunning')).toHaveTextContent('true');
+
+ await runTimers();
+
+ expect(screen.getByTestId('value')).toHaveTextContent('2');
+ expect(screen.getByTestId('isRunning')).toHaveTextContent('false');
+ });
+
+ test('should not rerun timer when a promise fails', async () => {
+ testing.useFakeTimers();
+ const doFunc = testing.fn().mockRejectedValue();
+
+ render();
+
+ const value = screen.getByTestId('value');
+ const isRunning = screen.getByTestId('isRunning');
+
+ expect(value).toHaveTextContent('0');
+ expect(isRunning).toHaveTextContent('false');
+
+ fireEvent.click(screen.getByTestId('startTimer'));
+
+ expect(value).toHaveTextContent('0');
+ expect(isRunning).toHaveTextContent('true');
+
+ await runTimers();
+
+ expect(screen.getByTestId('value')).toHaveTextContent('1');
+ expect(screen.getByTestId('isRunning')).toHaveTextContent('false');
+ });
+
+ test('should allow to clear the timer', async () => {
+ testing.useFakeTimers();
+ const doFunc = testing.fn().mockResolvedValue();
+
+ render();
+
+ const value = screen.getByTestId('value');
+ const isRunning = screen.getByTestId('isRunning');
+
+ expect(value).toHaveTextContent('0');
+ expect(isRunning).toHaveTextContent('false');
+
+ fireEvent.click(screen.getByTestId('startTimer'));
+
+ expect(value).toHaveTextContent('0');
+ expect(isRunning).toHaveTextContent('true');
+
+ await runTimers();
+
+ expect(screen.getByTestId('value')).toHaveTextContent('1');
+ expect(screen.getByTestId('isRunning')).toHaveTextContent('true');
+
+ fireEvent.click(screen.getByTestId('clearTimer'));
+
+ expect(screen.getByTestId('value')).toHaveTextContent('1');
+ expect(screen.getByTestId('isRunning')).toHaveTextContent('false');
+ });
+});
diff --git a/src/web/hooks/useTiming.js b/src/web/hooks/useTiming.js
new file mode 100644
index 0000000000..07ce4acad4
--- /dev/null
+++ b/src/web/hooks/useTiming.js
@@ -0,0 +1,107 @@
+/* SPDX-FileCopyrightText: 2024 Greenbone AG
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import {useEffect, useCallback, useState} from 'react';
+
+import logger from 'gmp/log';
+
+import {hasValue, isFunction} from 'gmp/utils/identity';
+
+import useInstanceVariable from './useInstanceVariable';
+
+const log = logger.getLogger('web.hooks.useTiming');
+
+/**
+ * Hook to start a timer that calls a function after a given timeout
+ *
+ * @param {Function} doFunc The function to call
+ * @param {Number|Function} timeout The timeout in milliseconds or a function that returns the timeout
+ * @returns Array of startTimer function, clearTimer function and boolean isRunning
+ */
+const useTiming = (doFunc, timeout) => {
+ const timer = useInstanceVariable({});
+ const [timerId, setTimerId] = useState(); // store timerId in state too to trigger re-render if it changes
+ const isRunning = Boolean(timerId);
+ timer.doFunc = doFunc; // always use the latest version of the function
+
+ const updateTimerId = useCallback(
+ newTimerId => {
+ timer.timerId = newTimerId;
+ setTimerId(newTimerId);
+ },
+ [timer],
+ );
+
+ const startTimer = useCallback(() => {
+ if (hasValue(timer.timerId)) {
+ log.debug('Not starting timer. A timer is already running.', {
+ timer: timer.timerId,
+ });
+ return;
+ }
+
+ const timeoutValue = isFunction(timeout) ? timeout() : timeout;
+
+ if (!hasValue(timeoutValue) || timeoutValue < 0) {
+ log.debug('Not starting timer because timeout value was', timeoutValue);
+ return;
+ }
+
+ updateTimerId(
+ setTimeout(async () => {
+ log.debug('Timer with id', timer.timerId, 'fired.');
+
+ const promise = timer.doFunc();
+ try {
+ if (promise?.then) {
+ await promise;
+ updateTimerId();
+ timer.startTimer();
+ } else {
+ updateTimerId();
+ }
+ } catch (error) {
+ updateTimerId();
+ }
+ }, timeoutValue),
+ );
+
+ log.debug(
+ 'Started timer with id',
+ timer.timerId,
+ 'and timeout of',
+ timeoutValue,
+ 'milliseconds',
+ );
+ }, [timeout, timer, updateTimerId]);
+
+ const clearTimer = useCallback(() => {
+ if (hasValue(timer.timerId)) {
+ log.debug('Clearing timer with id', timer.timerId);
+
+ clearTimeout(timer.timerId);
+ updateTimerId();
+ }
+ }, [timer, updateTimerId]);
+
+ useEffect(() => {
+ // put starTimer func into timer ref to allow referencing the NEWEST version
+ // when a promise has ended
+ timer.startTimer = startTimer;
+ });
+
+ // clear timer on unmount
+ useEffect(
+ () => () => {
+ log.debug('Removing timer on unmount');
+ clearTimer();
+ },
+ [clearTimer],
+ );
+
+ return [startTimer, clearTimer, isRunning];
+};
+
+export default useTiming;