Lessons Learned: Avoiding Recursion in Feathers.js After Hooks
Adam C. |

While working on a recent Feathers.js project, I encountered a challenge related to modifying service results in an after hook. Specifically, I needed to append a calculated value (in my case, a rank) to each record. However, the way I initially approached the problem led to an unintended recursion issue, where the after hook triggered itself repeatedly.

Photo by Mario Mesaglio on Unsplash

In this note, I’ll walk through what I learned and share how I resolved the issue by using direct database queries instead of context.service.find. This could be useful for anyone encountering similar recursion problems in Feathers.js after hooks.

The Problem: Recursive Behavior with context.service.find

In Feathers.js, after hooks are a great way to modify the data returned by a service before sending it back to the client. My goal was to return a list of records (e.g., performance times) and calculate a rank for each based on their position relative to others.

Initially, I used context.service.find to query the database from within the after hook. However, this approach caused the after hook to re-trigger itself every time the service method was called, resulting in an infinite loop. Here's a simplified version of the problematic code:

module.exports = (options = {}) => {
  return async (context) => {
    // This causes recursion!
    const totalRecordsBefore = await context.service.find({
      query: {
        intTime: { $lt: context.result.data[0].intTime }
      }
    });

    let currentRank = totalRecordsBefore + 1;
    context.result.data.forEach((record, index) => {
      record.rank = currentRank + index;
    });

    return context;
  };
};

Since context.service.find internally triggers the service method again, the after hook kept calling itself, leading to the recursion issue.

The Solution: Using Direct Database Queries

The key realization was that Feathers.js hooks still have access to the underlying database model (e.g., Mongoose or Sequelize). By using the model directly for queries, I could bypass the service layer and avoid re-triggering the after hook.

Here’s how I adjusted the code to query the database directly using Mongoose’s Model.countDocuments() instead of context.service.find:

module.exports = (options = {}) => {
  return async (context) => {
    const { Model } = context.service; // Access the database model (e.g., Mongoose, Sequelize, etc.)

    // Check if there are results to rank
    if (context.params.query["ageGroup"] && context.result.total > 0) {
      const firstRecord = context.result.data[0]; // Get the first record to base the rank on

      // Build the query from context.params.query but remove $limit, $skip, $sort
      const query = { ...context.params.query };
      let nextRank = query.$skip;
      delete query.$limit;
      delete query.$skip;
      delete query.$sort;

      // Add the intTime condition dynamically to the query
      query.intTime = { $lt: firstRecord.intTime };

      // console.log(query);

      // Query the database directly using the model (Mongoose example)
      const totalRecordsBefore = await Model.countDocuments(query);

      let currentRank = totalRecordsBefore + 1; // Start rank based on records before

      // Loop through the results to add ranks
      context.result.data.forEach((record, index) => {
        // Assign the rank to the current record
        nextRank++;    
        if (
          index > 0 &&
          record.intTime !== context.result.data[index - 1].intTime
        ) {
          currentRank = nextRank;
        }
        record.rank = currentRank;
      });
    }

    return context; // Return the modified context with ranked results
  };
};

Why This Solution Works

Using the model to query the database directly avoids the recursion issue because it doesn’t invoke the Feathers service method again. Here’s what I learned:

  • Direct database queries prevent recursion: By querying the database directly (in this case, using Model.countDocuments() for Mongoose), I was able to fetch the necessary data without triggering the after hook multiple times.
  • More control over database operations: Accessing the model directly gave me more flexibility in how I structured my queries. This allowed me to handle more complex logic, such as calculating ranks and managing pagination.
  • Handling pagination and ties: Since my results were paginated, I needed to ensure that ranks were calculated properly across pages. Ties were also handled by assigning the same rank to records with identical values.

Final Thoughts

This experience taught me the importance of understanding how Feathers.js hooks interact with the service layer. In cases where you need to modify results in an after hook (like calculating ranks), using context.service.find can lead to recursion problems. Instead, directly accessing the model to query the database provides a simple and effective solution.

I hope sharing this approach helps others who might run into similar issues with Feathers.js. If you're dealing with recursion or need to query data in an after hook, I recommend using the model directly for querying the database.