DEV Community

Cover image for Implementing an API with Background Tasks: A Pragmatic Approach
Athreya aka Maneshwar
Athreya aka Maneshwar

Posted on

Implementing an API with Background Tasks: A Pragmatic Approach

APIs are the backbone of modern applications, but sometimes they need to do more than just CRUD operations.

Consider a scenario where you need to update a user’s profile while also sending background notifications and emails—efficiently and without blocking the main request.

Let’s walk through how to achieve this using a structured, functional programming-inspired approach.

The Problem Statement

We need to implement an Update User API that:

  1. Accepts an update request for a username or phone number.
  2. Identifies the changed field.
  3. Updates the database accordingly.
  4. Fetches the user’s device token and sends a notification in the background.
  5. Sends an email to the user asynchronously.
  6. Returns an appropriate response to the client.

Why a Functional Approach?

John Carmack, a legendary game developer, argues that functional programming reduces side effects and improves software reliability.

In his view, many flaws in software development arise because programmers don’t fully understand all the possible states their code may execute in. This issue is magnified in multithreaded environments, where race conditions can lead to unpredictable behavior.

Functional programming mitigates these problems by making state explicit and reducing unintended side effects.

Even though we’re not using Haskell or Lisp, we can still apply functional programming principles in mainstream languages like JavaScript and TypeScript.

As Carmack puts it, "No matter what language you work in, programming in a functional style provides benefits."

By designing our API with pure functions, immutability, and clear separation of concerns, we improve testability, maintainability, and scalability.

Image description

Designing the API

1. Route Definition

We define an endpoint in our backend framework (e.g., Nest.js, Express, or Django):

PATCH /api/user/update
Enter fullscreen mode Exit fullscreen mode

2. Request Payload

The request should include the fields that need to be updated:

{
  // "userId": "12345",
  "username": "newUser123",
  "phoneNumber": "9876543210"
}
Enter fullscreen mode Exit fullscreen mode

3. Implementation in Node.js (Nest.js Example)

We follow a structured approach to separate concerns and keep our functions testable and reusable.

import { Controller, Patch, Body } from '@nestjs/common';
import { UserService } from './user.service';
import { NotificationService } from './notification.service';
import { EmailService } from './email.service';

@Controller('user')
export class UserController {
  constructor(
    private readonly userService: UserService,
    private readonly notificationService: NotificationService,
    private readonly emailService: EmailService,
  ) {}

  @Patch('update')
  async updateUser(@Body() body: any) {
    const { userId, username, phoneNumber } = body;

    // Check what has changed
    const updates: Partial<User> = {};
    if (username) updates['username'] = username;
    if (phoneNumber) updates['phoneNumber'] = phoneNumber;

    // Update user in DB (Pure function approach)
    const updatedUser = await this.userService.updateUser(userId, updates);

    // Fetch user token and send notification (background)
    this.notificationService.sendUserUpdateNotification(userId);

    // Send email (background)
    this.emailService.sendUpdateEmail(updatedUser);

    return { message: 'User updated successfully', data: updatedUser };
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Database Update Function (Pure Function Approach)

The following function updates the database and ensures data consistency:

async updateUser(userId: string, updates: Partial<User>) {
  return this.userModel.findByIdAndUpdate(userId, updates, { new: true });
}
Enter fullscreen mode Exit fullscreen mode

John Carmack emphasizes that pure functions only operate on their inputs and return computed values without modifying shared state. This approach ensures:

  • Thread safety: No unintended side effects.
  • Reusability: Easy to transplant into new environments.
  • Testability: Always returns the same output for the same input.

5. Background Notification Handling

We fetch the user’s token and send a push notification without blocking the main request.

async sendUserUpdateNotification(userId: string) {
  const userDevice = await this.userDeviceModel.findOne({ userId });
  if (userDevice?.token) {
    pushNotificationService.send(userDevice.token, 'Your profile has been updated. If it was not done by you, please contact support.');
  }
}
Enter fullscreen mode Exit fullscreen mode

6. Sending Emails in the Background

Emails can be slow, so we offload them to a worker queue like BullMQ/ Kafka:

async sendUpdateEmail(user: User) {
  emailQueue.add('sendEmail