·5 min read

Asynchronous serverless processing with in-app notifications using QStash and novu

Andreas ThomasAndreas ThomasSoftware Engineer @Upstash

Today we are looking at a common problem for many developers: A user has kicked of a task, which you want to process in a serverless function. If that task may fail, you might want to retry it automatically and also let the user know about the outcome.

To solve this, we will use QStash together with novu:

  • QStash is an HTTP based messaging and scheduling solution for serverless and edge runtimes.
  • Novu offers simple APIs for managing all communication channels in one place: Email, SMS, Direct, and Push.

Prerequisites

To follow this tutorial, you need accounts for 3 services:

What will we build?

To demonstrate this usecase, we will build a very simple app. The app allows a user to add two numbers together while randomly throwing an error to simulate a more complex scenario. All of the "business" logic is running inside a serverless function. After the calculations are done (or have failed) the user will be notified in your app using novu.

Obviously this setup is overkill for just adding two numbers, but the same setup can be applied to much more complex workflows.

Setup

  1. Go to console.upstash.com/qstash and make a note of QSTASH_TOKEN, QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY variables. You'll need these later.
  2. Create a new notification template in novu
  3. Enter a notification name like add.success
  4. And then add the In-App step to your trigger
  5. Create another notification template for add.failure and also add the In-App step.
  6. Go to web.novu.co/settings and copy your api key and application identifier, you'll need those later.

Create the app

Let's create a new Next.js app: npx create-next-app@latest --ts and then open your favourite code editor in the new directory.

Installing Dependencies

Both QStash and novu expose an HTTP API but to make it easier to work with them, we will install the official javascript sdks:

npm install @upstash/qstash @novu/node @novu/notification-center

Creating the api routes

/api/add

This route will be called by the frontend and receives two numbers to be added together. We will publish a message to QStash and delegate the actual execution to another serverless function. The only real purpose of this function is to use secrets to authenticate with other services, instead of exposing them in the frontend.

/api/add.ts
import assert from "assert";
import { NextApiRequest, NextApiResponse } from "next";
 
import { Client } from "@upstash/qstash";
 
export default async function (
  req: NextApiRequest,
  res: NextApiResponse,
): Promise<void> {
  try {
    const qstashToken = process.env.QSTASH_TOKEN;
    assert(qstashToken, "QSTASH_TOKEN is not defined");
 
    const qstash = new Client({
      token: qstashToken,
    });
 
    const { userId, x, y } = req.body as {
      userId: string;
      x: number;
      y: number;
    };
 
    await qstash.publishJSON({
      url: `https://${process.env.VERCEL_URL}/api/task/process`,
      retries: 3,
      body: {
        userId,
        x,
        y,
      },
    });
 
    res.status(201);
    res.send("OK");
    return;
  } catch (error) {
    console.error((error as Error).message);
    res.status(500).json({ error: (error as Error).message });
  } finally {
    res.end();
  }
}
/api/add.ts
import assert from "assert";
import { NextApiRequest, NextApiResponse } from "next";
 
import { Client } from "@upstash/qstash";
 
export default async function (
  req: NextApiRequest,
  res: NextApiResponse,
): Promise<void> {
  try {
    const qstashToken = process.env.QSTASH_TOKEN;
    assert(qstashToken, "QSTASH_TOKEN is not defined");
 
    const qstash = new Client({
      token: qstashToken,
    });
 
    const { userId, x, y } = req.body as {
      userId: string;
      x: number;
      y: number;
    };
 
    await qstash.publishJSON({
      url: `https://${process.env.VERCEL_URL}/api/task/process`,
      retries: 3,
      body: {
        userId,
        x,
        y,
      },
    });
 
    res.status(201);
    res.send("OK");
    return;
  } catch (error) {
    console.error((error as Error).message);
    res.status(500).json({ error: (error as Error).message });
  } finally {
    res.end();
  }
}

/api/process

Here we receive the message from QStash and do the calculation. We also introduce a random error component just to highlight the retry capabilities of QStash.

/api/process.ts
import assert from "assert";
import { NextApiRequest, NextApiResponse } from "next";
 
import { Novu } from "@novu/node";
import { verifySignature } from "@upstash/qstash/nextjs";
 
async function handler(req: NextApiRequest, res: NextApiResponse) {
  try {
    const { userId, x, y } = req.body as {
      userId: string;
      x: number;
      y: number;
    };
 
    const novuApiKey = process.env.NOVU_API_KEY;
    assert(novuApiKey, "NOVU_API_KEY is not defined");
    const novu = new Novu(novuApiKey);
 
    const rng = Math.random();
    const success = rng > 0.5;
 
    if (success) {
      await novu.trigger("add.success", {
        to: {
          subscriberId: userId,
        },
        payload: {
          x,
          y,
          result: x + y,
        },
      });
      return res.send("ok");
    }
 
    if (!success) {
      const error = "simulated error";
      await novu.trigger("add.failure", {
        to: {
          subscriberId: userId,
        },
        payload: {
          x,
          y,
          error,
        },
      });
      return res.status(500).send(error);
    }
  } catch (error) {
    console.error((error as Error).message);
    res.status(500).json({ error: (error as Error).message });
  } finally {
    res.end();
  }
}
 
export default verifySignature(handler);
 
export const config = {
  api: {
    bodyParser: false,
  },
};
/api/process.ts
import assert from "assert";
import { NextApiRequest, NextApiResponse } from "next";
 
import { Novu } from "@novu/node";
import { verifySignature } from "@upstash/qstash/nextjs";
 
async function handler(req: NextApiRequest, res: NextApiResponse) {
  try {
    const { userId, x, y } = req.body as {
      userId: string;
      x: number;
      y: number;
    };
 
    const novuApiKey = process.env.NOVU_API_KEY;
    assert(novuApiKey, "NOVU_API_KEY is not defined");
    const novu = new Novu(novuApiKey);
 
    const rng = Math.random();
    const success = rng > 0.5;
 
    if (success) {
      await novu.trigger("add.success", {
        to: {
          subscriberId: userId,
        },
        payload: {
          x,
          y,
          result: x + y,
        },
      });
      return res.send("ok");
    }
 
    if (!success) {
      const error = "simulated error";
      await novu.trigger("add.failure", {
        to: {
          subscriberId: userId,
        },
        payload: {
          x,
          y,
          error,
        },
      });
      return res.status(500).send(error);
    }
  } catch (error) {
    console.error((error as Error).message);
    res.status(500).json({ error: (error as Error).message });
  } finally {
    res.end();
  }
}
 
export default verifySignature(handler);
 
export const config = {
  api: {
    bodyParser: false,
  },
};

Frontend

To display the notifications in your UI, we added a very simple UI, you can simply use novu's react components or create a custom UI like we did here.

Run it on Vercel

The simplest way to run your Next.js app is to use Vercel's CLI:

$ npx vercel

After it is deployed, you need to add all the environment variables. Head over to vercel.com and go to your project's settings.

After you have updated the environment variables, run npx vercel --prod to make them take effect.

Try it out

If you have added your UI, you can check out your app by going to the link provided by Vercel, otherwise check out the hosted app of this blog post: qstash-with-novu.vercel.app. Enter two numbers and click the Add button. After a short time, you will see a notification in the top right. Either it has successfully added the two numbers, or it will let you know it encountered an error. In case of an error, it will be retried automatically and a new notification will show up shortly.

Summary

While this usecase was very basic, think about all of the asynchronous tasks your users could kick off and if they would be a good fit to run in serverless functions.

Together with QStash and novu, you have a perfect stack to make your app failure resistant and provide feedback to the user without managing more infrastructure.

Let us know your feedback on Twitter or Discord.