This time, I want to share one of my favorite Node.js development tips. Once you understand the event loop, you’ll realize how important it is to manage execution efficiency in your code so the event loop can work as expected.

I often use setTimeout to achieve a periodic execution similar to setInterval, but without falling into callback hell. With this approach, you can adjust the interval dynamically after each execution, making it flexible and easy to maintain.

Below is a sample implementation I use frequently—a PollingTask class that wraps your periodic task. Just pass in an async function and your desired interval (in milliseconds), and you’ll get a scheduler that ensures each run waits for the previous one to finish (no overlap), with proper error handling so the scheduler won’t crash.

// ==========================================================
// PollingTask
// ==========================================================

class PollingTask {
    // Use '#' to declare private properties, protecting internal state from outside access
    #handle = null;
    #intervalMs;
    #taskToRun;
    #isRunning = false;

    /**
     * @param {function} taskToRun The asynchronous function you want to run periodically (should be async or return a Promise)
     * @param {number} intervalMs The interval between each execution (milliseconds)
     */
    constructor(taskToRun, intervalMs) {
        this.#taskToRun = taskToRun;
        this.#intervalMs = intervalMs;
    }

    // The core internal execution function, marked as async
    async #run() {
        // If the task has been stopped externally, exit the loop immediately
        if (!this.#isRunning) return;
        try {
            // *** Key Point 1: await ***
            // Wait for the provided async task to complete.
            // The code pauses here until the Promise from this.#taskToRun() is resolved.
            await this.#taskToRun();
        } catch (error) {
            // *** Key Point 2: try...catch ***
            // If this.#taskToRun() throws an error or returns a rejected Promise,
            // this catch block will handle it, ensuring the scheduler doesn’t crash.
            console.error(`[PollingTask] Error during task execution:`, error);
        } finally {
            // *** Key Point 3: finally ***
            // The code here runs regardless of success or failure.
            // This is the perfect place to schedule the next execution.
            if (this.#isRunning) {
                this.#handle = setTimeout(() => this.#run(), this.#intervalMs);
            }
        }
    }

    /**
     * Start the periodic task
     */
    start() {
        if (this.#isRunning) {
            console.warn("[PollingTask] Task is already running.");
            return;
        }
        console.log(`[PollingTask] Task started with an interval of ${this.#intervalMs}ms.`);
        this.#isRunning = true;
        // Start the first execution immediately, instead of waiting for the first interval
        this.#run();
    }

    /**
     * Stop the periodic task
     */
    stop() {
        if (!this.#isRunning) return;
        console.log("[PollingTask] Task stopped.");
        this.#isRunning = false;
        clearTimeout(this.#handle);
        this.#handle = null;
    }
}

// 1. Define your state and asynchronous job logic
const sqlTaskState = { mstate: 0 };

// Use async to define your job, so it automatically returns a Promise
const mySqlJob = async () => {
    console.log(`Executing job with state: ${sqlTaskState.mstate}`);
    switch (sqlTaskState.mstate) {
        case 0:
            console.log("State 0: Initializing...");
            // Use await to simulate an asynchronous job (such as API call or DB query)
            await new Promise(resolve => setTimeout(resolve, 500));
            sqlTaskState.mstate = 1;
            break;
        case 1:
            console.log("State 1: Processing data...");
            await new Promise(resolve => setTimeout(resolve, 500));
            // Throw to simulate a failure, which will be caught by PollingTask’s catch block
            if (Math.random() > 0.8) { 
                 throw new Error("Failed to connect to the database!");
            }
            sqlTaskState.mstate = 0; // Return to initial state
            break;
    }
};

// 2. Create a PollingTask instance
const myScheduler = new PollingTask(mySqlJob, 2000);

// 3. Start the task
myScheduler.start();

// 4. Stop the task after 10 seconds as a demonstration
setTimeout(() => {
    myScheduler.stop();
}, 10000);

The biggest advantage of this design is no callback hell—each execution waits for the previous one to finish before scheduling the next, and errors won’t crash the whole task. You can easily adjust the interval and extend functionality as needed.

If you have periodic jobs—like interacting with APIs or databases—and want to keep your event loop healthy, I highly recommend this approach!