javascript

Find and Fix Fastify Slowdowns with AppSignal for Node.js

Damilola Olatunji

Damilola Olatunji on

Find and Fix Fastify Slowdowns with AppSignal for Node.js

In part one of this series, we set up basic performance monitoring for our Fastify application using AppSignal and explored key performance indicators.

Now that we have our monitoring foundation in place, it's time to leverage these insights to actively improve application performance.

You'll learn how to detect performance regressions, find optimization opportunities, and implement custom instrumentation with OpenTelemetry.

I'll show you how to use dashboards in AppSignal to identify bottlenecks, compare performance across deployments, and set up application monitoring beyond performance tracking.

Let's get started!

Detecting Performance Regressions in Fastify for Node

It's not enough to track performance metrics alone; you also need to understand how your application's performance evolves over time. When new code introduces regressions, you want to identify and fix them quickly.

The first step toward detecting regressions is linking performance data to specific application versions. If you're using Git, connect the specific commit hash to your performance data by modifying your appsignal.js file:

JavaScript
// appsignal.js import childProcess from "node:child_process"; import { Appsignal } from "@appsignal/nodejs"; const REVISION = childProcess.execSync("git rev-parse --short HEAD").toString(); new Appsignal({ active: true, name: "<Your App Name>", revision: REVISION, // sets the Git revision });

Once enabled, AppSignal automatically creates Deploy Markers for each deployment, allowing you to filter issue data by specific versions:

AppSignal Deploy Markers

You can also view performance trends in Summary > Performance in the last deploys, which displays mean response times across deployments along with sample counts:

AppSignal performance in the last deploys

In this example, the /user-balances route shows significantly worse performance in the latest deployment, suggesting a potential regression.

From here, you can determine whether this regression affects your entire application or just this specific route by examining recent samples to see the request breakdown, or by comparing source control revisions to identify exactly what changed between versions.

Finding Opportunities for Node.js App Performance Improvements

Thanks to AppSignal's automatic instrumentation with popular Node.js libraries, you can easily identify the most urgent areas for performance improvement in your application.

Under the Performance menu, you'll find Slow API requests and Slow queries. These sections rank your API endpoints and database queries by performance impact, helping you prioritize optimizations for maximum benefit.

Slow queries in AppSignal

The Slow events view aggregates all instrumented events into a single dashboard, allowing you to pinpoint expensive operations across your entire application.

Slow events in AppSignal

Clicking on an event group like fetch shows you the slowest events in that group:

Slow fetch events in AppSignal

To see more details, click the Name of the event you're investigating. You'll see performance graphs and, most importantly, the actions where these events occur.

Event detail page

Scroll down to the bottom of this page and click on the most recent incident:

Clicking on an incident in AppSignal's event page

Then find the most recent sample, and click on it:

Clicking on a sample

Analyzing the Event Timeline

In your Timeline, patterns of many small, sequential events often indicate inefficient N+1 queries, as seen in the /customers-with-orders route, where the query count grows with the number of customers and orders:

JavaScript
fastify.get('/customers-with-orders', async (request, reply) => { try { // [...] // N+1 problem: For each customer, we make a separate query to get their orders for (const customer of customers) { // This is the "+N" part - one additional query per customer const orders = await db('orders') .where('customer_id', customer.customer_id) .select('order_id', 'order_date', 'shipped_date', 'freight'); // Attach orders to each customer customer.orders = orders; // For each order, we make another query to get order details - making this even worse! for (const order of orders) { // [...] } } // [...] } catch (error) { request.log.error(error); return reply.code(500).send({ error: 'Failed to retrieve customer data' }); } });
Event timeline showing N+1 query

You can optimize this route to use only three queries regardless of data size:

JavaScript
// server.js fastify.get('/customers-with-orders', async (request, reply) => { try { // 1️⃣ Fetch all customers (basic information only) const customers = await db('customers').select( 'customer_id', 'company_name', 'contact_name' ); // 2️⃣ Fetch all orders for all customers in a single query // Uses WHERE IN to get only the orders belonging to the retrieved customers const orders = await db('orders') .whereIn( 'customer_id', customers.map((c) => c.customer_id) ) .select( 'order_id', 'customer_id', 'order_date', 'shipped_date', 'freight' ); // 3️⃣ Fetch all order details (line items) for all orders in a single query // Joins with the products table to include product names const orderIds = orders.map((o) => o.order_id); const orderDetails = await db('order_details') .whereIn('order_id', orderIds) .join('products', 'order_details.product_id', 'products.product_id') .select( 'order_details.order_id', 'order_details.unit_price', 'order_details.quantity', 'order_details.discount', 'products.product_name' ); // 4️⃣ Organize order details by order_id for faster lookup const orderDetailsMap = {}; orderDetails.forEach((detail) => { if (!orderDetailsMap[detail.order_id]) { orderDetailsMap[detail.order_id] = []; } orderDetailsMap[detail.order_id].push(detail); }); // 5️⃣ Group orders by customer_id and attach order details to each order const ordersByCustomer = {}; orders.forEach((order) => { if (!ordersByCustomer[order.customer_id]) { ordersByCustomer[order.customer_id] = []; } // Attach items (order details) to each order order.items = orderDetailsMap[order.order_id] || []; ordersByCustomer[order.customer_id].push(order); }); // 6️⃣ Attach orders to each customer customers.forEach((customer) => { customer.orders = ordersByCustomer[customer.customer_id] || []; }); // 7️⃣ Return combined customer–order data with total customer count return { customerCount: customers.length, customers, }; } catch (error) { request.log.error(error); return reply.code(500).send({ error: 'Failed to retrieve customer data' }); } });

This route efficiently retrieves all customers and their related orders and order details from a database while avoiding the N+1 query problem by batching lookups. It performs only three SQL queries in total: one to get all customers, one to get all their orders, and one to get all order details.

After making this change, commit it and send more traffic to the endpoint. In a few minutes, you'll see the route's performance improve significantly compared to the previous commit:

AppSignal performance comparison

Examining a sample reveals the efficiency improvements, with the results now only requiring three SQL queries:

Event timeline after optimizing route

This optimization significantly reduces a route's performance impact, as reflected in the Issues list when viewing the Last deploy:

Performance impact of /customers-with-orders

You can apply similar analysis to other routes like /user-balances, using techniques such as parallel requests with Promise.all() and response caching to further improve performance.

Setting up Custom Instrumentation with OpenTelemetry

While automatic instrumentation covers most high-level metrics needed for baseline performance monitoring, you'll often need deeper visibility into specific performance-critical code sections.

AppSignal leverages OpenTelemetry's trace API under the hood, making it straightforward to implement custom performance tracking.

Shell
npm install @opentelemetry/api

Get a tracer instance as follows:

JavaScript
import { trace } from "@opentelemetry/api"; const tracer = trace.getTracer("example-app");

With your tracer configured, wrap any performance-critical code section in an active span:

JavaScript
fastify.get("/example", async (req, res) => { tracer.startActiveSpan("perform some operation", async (span) => { await someOperation(); // track this operation span.end(); // Don't forget to end the span here }); });

Once implemented, these custom spans will appear in your performance timeline, enabling you to:

  • Isolate bottlenecks in critical code paths.
  • Track performance trends over time.
  • Set targeted alerts for performance regressions.

Comprehensive Monitoring with AppSignal: Host Metrics, Logs, and Notifications

Beyond performance tracking, AppSignal offers a suite of integrated monitoring features that provide a complete view of your application's health.

For example, system-level metrics are automatically tracked under Host monitoring > Host metrics, allowing you to determine if slowdowns stem from application code or resource constraints.

AppSignal Host Metrics

You can also enhance your troubleshooting workflow by consolidating your Fastify logs in AppSignal through the Pino transport:

JavaScript
const fastify = Fastify({ logger: { transport: { targets: [ { target: "@appsignal/nodejs/pino", options: { group: "my-app" } }, ], }, }, });

Your logs will then appear in the Logging > All logs section, providing context to performance data and other application behaviors:

AppSignal logging

Stay ahead of issues with AppSignal's notification capabilities, which allow you to set precise performance boundaries and receive alerts when metrics cross defined thresholds:

AppSignal set alerts

Or you can go to Anomaly detection > Triggers to configure triggers and receive alerts when metrics deviate from your configured limits:

AppSignal set alerts

Wrapping Up

In this post, we've covered how AppSignal helps you collect relevant metrics from your application, find optimization opportunities, and detect performance regressions quickly.

With these tools and techniques, you're now equipped to maintain the high performance that drew you to Fastify in the first place, even as your application grows in complexity and scale.

Remember that performance monitoring isn't just about collecting data. It's about turning that data into actionable insights that lead to real improvements for your users. With AppSignal, doing this should be a breeze.

Thanks for reading!

Wondering what you can do next?

Finished this article? Here are a few more things you can do:

  • Share this article on social media
Damilola Olatunji

Damilola Olatunji

Damilola is a freelance technical writer and software developer based in Lagos, Nigeria. He specializes in JavaScript and Node.js, and aims to deliver concise and practical articles for developers. When not writing or coding, he enjoys reading, playing games, and traveling.

All articles by Damilola Olatunji

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps