Twilight
← Back to all posts

Understanding Next.js after() function and implementing it in Express.js

August 26, 2025
8 min read

Introduction

Have you ever wondered how Next.js manages to send responses to users immediately while still running background tasks? The after() function is a powerful built-in feature in Next.js that allows you to schedule tasks to run after the response has been sent to the client. This means users get faster responses while your application continues working in the background.

In this article, we'll first explore how the actual Next.js after() function works, and then learn how to implement a similar pattern in an Express.js application. This will help you understand both the official Next.js approach and how you can achieve similar functionality in other frameworks.

What is the Next.js after() function?

The after() function in Next.js is a built-in utility that schedules work to be executed after a response (or prerender) is finished. It's incredibly useful for tasks and side effects that should not block the response, such as:

  • Logging and analytics
  • Sending notifications
  • Processing images or files
  • Updating search indexes
  • Any task that doesn't need to block the user's response

How Next.js after() works

The Next.js after() function is much simpler than our Express.js implementation because Next.js handles all the complexity internally. Here's how it works:

Basic Usage

import { after } from 'next/server'
 
export default function Layout({ children }: { children: React.ReactNode }) {
  after(() => {
    // This runs after the layout is rendered and sent to the user
    console.log('Layout rendered successfully')
  })
  
  return <>{children}</>
}

In Route Handlers and Server Actions

import { after } from 'next/server'
import { cookies, headers } from 'next/headers'
 
export async function POST(request: Request) {
  // Perform your main operation
  const result = await processData(request)
  
  // Schedule background work
  after(async () => {
    const userAgent = (await headers()).get('user-agent') || 'unknown'
    const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous'
    
    // Log user activity for analytics
    await logUserAction({ sessionCookie, userAgent })
  })
  
  return new Response(JSON.stringify({ status: 'success' }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  })
}

Key Features of Next.js after()

  • Automatic execution: Runs after the response is sent, even if there's an error
  • Request API access: Can access cookies, headers, and other request data in Route Handlers and Server Actions
  • No dynamic rendering: Doesn't cause routes to become dynamic
  • Configurable timeouts: Respects platform timeout limits
  • Nesting support: Can be nested inside other after calls

Implementing a similar pattern in Express.js

Now let's implement a similar pattern in Express.js. This will help you understand how the underlying mechanism works and how you can achieve similar functionality in other frameworks.

How it works

The implementation involves several key concepts that work together. Let's break them down:

1. Understanding Async Context

First, we need to understand how Node.js handles asynchronous operations. When a request comes in, we want to track certain information (like scheduled tasks) throughout the entire request lifecycle, even across multiple async operations.

We use AsyncLocalStorage from Node.js, which is like a "container" that holds data specific to a single request. Think of it as a backpack that follows your request around - no matter where the request goes in your code, it always has access to its backpack.

import { AsyncLocalStorage } from 'node:async_hooks';
 
interface RequestContext {
    // This array will store all the background tasks we want to run later
    tasks: Array<() => Promise<unknown>>;
}
 
// Create a storage container that can hold our request context
const requestWorkerContext = new AsyncLocalStorage<RequestContext>()

2. Creating Helper Functions

Now let's create functions to work with our request context:

// This function gets the current request's context (like opening the backpack)
export function getRequestContext() {
    const ctx = requestWorkerContext.getStore();
 
    if (!ctx) {
        throw new Error('Request context not found');
    }
 
    return ctx;
}
 
// This function creates a new context and runs code within it
export function runInRequestWorkerContext(fn: () => unknown) {
    const ctx = {
        tasks: [], // Start with an empty array of tasks
    };
 
    // Run the function within this context
    return requestWorkerContext.run(ctx, fn);
}
 
// This function runs all the scheduled tasks
export async function runScheduledTasks() {
    const ctx = getRequestContext();
 
    // Run each task one by one (sequentially)
    // You could use Promise.all() here if you want tasks to run in parallel
    for (const task of ctx.tasks) {
        await task();
    }
 
    // Clear the tasks array after running them
    ctx.tasks.length = 0;
}

3. Creating the after() function

The after() function is surprisingly simple - it just adds a task to our request's task list:

export function after(fn: () => unknown) {
    const ctx = getRequestContext();
    ctx.tasks.push(fn); // Add the function to our task list
}

4. Setting up the Middleware

Middleware in Express.js is code that runs before your route handlers. We need to create middleware that:

  1. Creates a context for each request
  2. Runs the request through your normal route handlers
  3. Executes all scheduled tasks after the response is sent
app.use((req, res, next) => {
    runInRequestWorkerContext(async () => {
        try {
            // This runs your normal route handler
            await next();
        } finally {
            // This runs AFTER the response is sent, regardless of success or error
            await runScheduledTasks();
        }
    });
})

5. Using the after() function

Now you can use the after() function in your route handlers! Here's a practical example:

app.post('/register', async (req, res) => {
    // First, create the user account in the database
    const user = await db.users.create({
        data: {
            email: req.body.email,
            password: hashedPassword,
            name: req.body.name,
        },
    });
 
    // Schedule a background task to run AFTER the response is sent
    after(async () => {
        // This will run in the background after the user gets their response
        await sendWelcomeEmail(user.email, user.name);
        
        // You could also do other things like:
        // - Send welcome SMS
        // - Create user profile
        // - Add to mailing list
        // - Log analytics
        // - Send onboarding notifications
    });
 
    // Send the response immediately - the user doesn't wait for emails!
    res.status(201).json({ 
        message: 'Account created successfully',
        userId: user.id 
    });
})

How the Flow Works

Let's trace through what happens when a request comes in:

  1. Request arrives: A user submits registration form
  2. Middleware creates context: Our middleware creates a new context with an empty tasks array
  3. Route handler runs: Your code creates the user account and calls after() to schedule a welcome email task
  4. Response sent: The response is sent to the user immediately
  5. Background tasks run: After the response is sent, all scheduled tasks (like sending welcome emails) run in the background
  6. Context cleaned up: The context is automatically cleaned up

Key Differences: Next.js vs Express.js Implementation

Setup

  • Next.js: Built-in, no setup required
  • Express.js Implementation: Requires custom middleware and context management

Request APIs

  • Next.js: Can access cookies, headers in Route Handlers
  • Express.js Implementation: Limited access to request data in after() functions

Error handling

  • Next.js: Automatically runs even on errors
  • Express.js Implementation: Requires try-finally in middleware

Performance

  • Next.js: Optimized by Next.js framework
  • Express.js Implementation: Custom implementation may have overhead

Flexibility

  • Next.js: Limited to Next.js ecosystem
  • Express.js Implementation: Can be adapted to any Node.js framework

Benefits of this approach

  • Faster user experience: Users get responses immediately
  • Better scalability: Your server can handle more requests since it's not waiting for slow operations
  • Improved reliability: If background tasks fail, users still get their response
  • Flexibility: You can schedule multiple tasks and they'll all run after the response

Common Use Cases

Here are some scenarios where the after() function is particularly useful:

  • User registration: Send welcome emails after confirming account creation
  • File uploads: Process images or generate thumbnails after confirming upload
  • E-commerce: Send order confirmations after processing payments
  • Social media: Notify followers after posting content
  • Analytics: Track user actions without slowing down their experience

When to use which approach

  • Use Next.js after(): When building Next.js applications - it's simpler and more optimized
  • Use Express.js implementation: When you need this pattern in Express.js or want to understand how it works under the hood
  • Build your own: When you need custom behavior or are working with other frameworks

Conclusion

The after() function pattern is a powerful way to improve your application's performance and user experience. Next.js provides this functionality out of the box, making it incredibly easy to use. By understanding how to implement it in Express.js, you gain insights into the underlying mechanisms and can adapt this pattern to other frameworks.

The key concepts we covered are:

  • Next.js after(): Built-in functionality for scheduling post-response tasks
  • AsyncLocalStorage: How to maintain context across async operations
  • Middleware: How to set up the infrastructure for background tasks
  • Task scheduling: How to defer work until after responses are sent
  • Context management: How to organize and execute background tasks

Remember, this pattern is especially valuable when you have operations that are important but don't need to block the user's immediate response. If you're using Next.js, take advantage of the built-in after() function. If you're using Express.js or want to understand the mechanics, the custom implementation provides valuable learning opportunities.

With this knowledge, you can now build faster, more responsive applications that provide a better user experience while maintaining all the functionality your users expect.