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', {
    to: user.email,
    subject: 'Profile Updated',
    body: 'Your profile has been successfully updated. If it was not done by you, please contact support.',
  });
}
Enter fullscreen mode Exit fullscreen mode

The Pragmatic Balance

Not everything can be purely functional—real-world applications need to interact with databases, file systems, and external services.

As Carmack notes, "Avoiding the worst in a broader context is generally more important than achieving perfection in limited cases."

Rather than enforcing strict purity everywhere, the goal is to minimize side effects in critical parts of our application while handling necessary mutations in a controlled manner.

Image description

Benefits of This Approach

  • Non-blocking: Background tasks ensure the API remains responsive.
  • Separation of concerns: The update logic, notifications, and email handling are independent.
  • Functional mindset: The database update function is pure, making it easier to test.
  • Scalability: Background processing scales better than synchronous execution.
  • Code reliability: Reduced side effects make debugging easier.

What’s Next?

If you’re interested in diving deeper into functional programming in backend development, consider:

  • Implementing retries and failure handling in background jobs.
  • Using event-driven architectures with Kafka or RabbitMQ.
  • Exploring functional programming libraries in JavaScript, like Ramda or Lodash/fp.

As John Carmack suggests, “Programming in a functional style provides benefits.

You should do it whenever it is convenient, and you should think hard about the decision when it isn’t convenient.”


I’ve been working on a super-convenient tool called LiveAPI.

LiveAPI helps you get all your backend APIs documented in a few minutes

With LiveAPI, you can quickly generate interactive API documentation that allows users to execute APIs directly from the browser.

Image description

If you’re tired of manually creating docs for your APIs, this tool might just make your life easier.

Top comments (0)