Skip to content

Promisified Server Startup

Wraps the event-based server.listen() API in a promise and returns a structured object with url, port, and listener. Includes development-mode retry logic for automatic port conflict resolution.

When to Use

  • Starting an Express (or any Node.js HTTP) server with await
  • Needing the URL, port, and server handle as a clean return value
  • Handling EADDRINUSE gracefully during development
  • Setting up graceful shutdown with listener.close()

The Pattern

The listen() helper wraps server startup in a promise and returns a structured object with url, port, and listener:

javascript
const { url, listener } = await listen(app, 3000);
console.log(`Running at ${url}`);

Graceful shutdown

Use the returned listener to close the server during cleanup:

javascript
const { url, listener } = await listen(app, port);
console.log(`Server running at ${url}`);

cleanupOnExit(async function cleanup() {
  if (cleanup.complete) return;
  await listener.close();
  process.kill(process.pid, "SIGHUP");
  cleanup.complete = true;
});

With auto port selection

Combine with a port finder for conflict-free startup:

javascript
const port = await getPort(8000);
const { url } = await listen(app, port);
console.log(`Server running at ${url}`);

Implementing listen()

javascript
import { createServer } from "node:http";

export async function listen(app, port) {
  const listener = createServer(app);
  return startServer();
  
  async function startServer() {
    try {
      await new Promise((resolve, reject) => {
        listener.listen(port);
        listener.once("listening", resolve);
        listener.once("error", reject);
      });
  
      const url = `http://localhost:${port}`;
      return { url, port, listener };
    } catch (error) {
      if (DEVELOPMENT && error.code === "EADDRINUSE") {
        console.warn(
          `Port ${port} in use, retrying...`,
        );
        await clearPort(port);
        await delay(1000);
        return startServer();
      }
      throw error;
    }
  }
}

Return value

The structured return gives each consumer exactly what it needs:

  • url — for display: console.log(url)
  • port — for configuration or passing to other services
  • listener — for lifecycle management: await listener.close()

Event-to-promise bridge

The core technique wraps Node's event-based API:

javascript
await new Promise((resolve, reject) => {
  listener.listen(port);
  listener.once("listening", resolve);
  listener.once("error", reject);
});

This is a general pattern for any event emitter that signals success with one event and failure with another.

Development-mode retry

In development, port conflicts are common. The retry logic kills the blocking process and tries again:

javascript
async function clearPort(port) {
  try {
    await sh`lsof -ti tcp:${port} | xargs kill -9`
      .run();
  } catch (error) {
    console.error(
      `Failed to clear port ${port}:`,
      error,
    );
  }
}

This only runs in development — production should fail immediately on port conflicts.

Trade-offs

ApproachProsCons
Promisified listen()Awaitable, structuredExtra wrapper
app.listen(cb)Built-in, simpleCallback-based
Event listenersFull controlVerbose

The structured return is the key value. Without it, you store the port in a variable and construct the URL by hand every time. The { url, port, listener } object gives every consumer a clean interface.