Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Dana Lawson
Ben Nadel at InVision In Real Life (IRL) 2018 (Hollywood, CA) with: Dana Lawson

You Can Continue To Process An Express.js Request After The Client Response Has Been Sent

By on

When I first starting digging into Express.js, my mental model tended to conflate the concept of the Express.js request with the concept of the Client Response. As I've gotten more comfortable with Express.js, however, I now see that the Client Response is simply an "aspect" of the Express.js request - not its entirety. And, in fact, we can continue to process the Express.js request - firing off more asynchronous actions - after the response has been closed.

Conceptually, an Express.js request is little more than a series of Functions (aka, middleware) that get called in series. The augmented Request and Response streams get passed into each one of these middleware Functions, where they can continue to be augmented and consumed. When we send a response to the Client, all we're doing is committing the Response stream. This has nothing to do with how the middleware functions behave. And, in fact, the middleware functions will continue to be run in serial as long as the code keeps calling the next() method.

A nice side-effect of this behavior is that we can hook into the Express.js global error handler(s) even after the response has been committed. This helps to keep our logging logic in one place and can prevent uncaught exceptions / unhandled Promise rejections from propagating beyond the boundary of the Express.js application.

To see what I mean, take a look at this tiny Express.js demo application. It provides one route that executes processing both before and after the Response stream is committed to the client. Notice, however, that the post-response processing still hooks back into the next() middleware via the Promise chain .catch() handler:

// Require the core node modules.
var chalk = require( "chalk" );
var express = require( "express" );

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

var app = express();

// Setup the Express router.
app.get(
	"/",
	function( request, response, next ) {

		getMessage()
			.then(
				( message ) => {

					// Close the client response.
					response
						.type( "text/plain" )
						.send( message )
					;

				}
			)
			// At this point, the CLIENT RESPONSE has been sent; but, that doesn't mean
			// that the Express.js request has completed. We can continue to process the
			// request, handling ASYNCHRONOUS aspects of the the client's request.
			// --
			// CAUTION: Since we're serializing the calls, essentially, it means that an
			// error in one will likely prevent the next one from being invoked. As such,
			// this approach may not always be appropriate.
			.then( enqueueSomething )
			.then( sendSomething )
			// If we hook all of this into the next() callback, it means that all of our
			// errors can be handled by the global error handler - even errors that occur
			// after the response has been sent to the client.
			.catch( next )
		;

	}
);

// Setup the Express global error handler.
app.use(
	function( error, request, response, next ) {

		console.log( chalk.red.bold( "ERROR" ) );
		console.log( chalk.red.bold( "=====" ) );
		console.log( error );

		// Because we hooking post-response processing into the global error handler, we
		// get to leverage unified logging and error handling; but, it means the response
		// may have already been committed, since we don't know if the error was thrown
		// PRE or POST response. As such, we have to check to see if the response has
		// been committed before we attempt to send anything to the user.
		if ( ! response.headersSent ) {

			response
				.status( 500 )
				.send( "Sorry - something went wrong. We're digging into it." )
			;

		}

	}
);

app.listen( "8080" );


// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

function getMessage() {

	return( Promise.resolve( "Come at me, bro!" ) );

}

function enqueueSomething() {

	var promise = new Promise(
		( resolve, reject ) => {

			setTimeout( resolve, 1000 );

		}
	);

	return( promise );

}

function sendSomething() {

	throw( new Error( "SendFailure" ) );

}

In this case, none of the processing does anything meaningful; but, in the sendSomething() function, you can see that we're throwing an error. Since some of these methods are being invoked after the response has been committed, we can't show this error to the user (immediately); but, we can still handle it in our Express.js global error handler. We just have to make sure that we test the Response state in the error handler before we try to send anything to the client. After all, you can't commit a response more than once.

If we run this Express.js application and make a request, we can see that the Client gets the success response and the global error handler captures the post-response exception:

You can process Express.js request after the response has been committed to the client.

As you can see, the Client received the 200 OK response with the calculated message. But, some time after the response has been committed, the sendSomething() function throws an error and our .catch() handler pipes that error into the Express.js global error handler.

I'm not saying that this approach is the right approach all of the time. Really, I'm just trying to drive home the point that the Express.js request and the Client response are not the same thing and should not be conflated. The Client response is nothing more than an aspect of the Request-Response life-cycle. And, we can continue to process the request even after the client respnose has been sent. Once you have this mental model, you can start to do interesting things, like leverage the global error handler for workflows that aren't immediately obvious.

Want to use code from this post? Check out the license.

Reader Comments

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel