Skip to content

Express Utility Handlers

Reusable Express middleware for common patterns: blocking requests until an async operation completes, and sending standard 404 responses.

When to Use

  • Delaying HTTP serving until a build, migration, or other startup task finishes
  • Adding a standard 404 handler at the end of the middleware chain
  • Gating routes behind async preconditions without custom middleware per route

The Pattern

Wait for database migration

Gate the entire server behind an async precondition using ExpressHandlers.waitUntilResolved():

javascript
import express from "express";
import { ExpressHandlers } from "./http.js";
import { runMigrations } from "./db.js";

const app = express();

const ready = runMigrations();
app.use(ExpressHandlers.waitUntilResolved(ready));

app.get("/api/users", listUsers);
app.listen(3000);

Requests to /api/users will block until migrations complete, then proceed normally.

Final 404 handler

Place ExpressHandlers.notFound at the end of the middleware chain to send standard 404 responses for unmatched requests:

javascript
app.use("/api", apiRouter);
app.use(uiServer);
app.use(ExpressHandlers.notFound);

Any request that falls through all previous middleware gets a 404 Cannot GET /path response.

Implementing ExpressHandlers

The ExpressHandlers class provides two static methods — one for gating requests behind an async operation, another for standard 404 responses:

javascript
class ExpressHandlers {
  /**
   * Blocks all requests until the given promise
   * resolves, then passes through to the next
   * middleware.
   */
  static waitUntilResolved(promise) {
    return async function (req, res, next) {
      await promise;
      next();
    };
  }
  
  /**
   * Sends a 404 response matching Express's
   * default format.
   */
  static notFound(req, res) {
    res.status(404).send(
      `Cannot ${req.method} ${req.originalUrl}`,
    );
  }
}

How waitUntilResolved works

It returns a middleware function that awaits the promise on every request. Once the promise settles, next() is called and the request proceeds. While the promise is pending, all requests queue behind it.

The promise is shared. Multiple requests don't each trigger the async operation — they all wait on the same promise instance.

Inline variant

The same pattern appears as a local function when the promise is created nearby. This version gates requests behind a dev build:

javascript
app.use(awaitDevBuild());
app.use(serveDistDir());

function awaitDevBuild() {
  const buildComplete = devBuild();
  return async function (req, res, next) {
    await buildComplete;
    next();
  };
}

Trade-offs

ApproachProsCons
waitUntilResolvedSimple, shared promiseBlocks all routes
Per-route async guardFine-grained controlRepetitive
Health check endpointDoesn't blockClient must poll

Use waitUntilResolved for all-or-nothing startup gates. If the server shouldn't serve anything until a precondition is met (build complete, DB ready), this is the simplest approach. For partial availability, use per-route guards instead.