Skip to content

Node servers

Any Node HTTP server, whatever the framework: Express, Fastify, Koa, Hapi, NestJS, Hono on @hono/node-server, with Prisma 7 and a driver adapter. Your server’s code does not change: a preload in the dev command profiles each request, and the profiler’s page gets a port of its own. Complete apps are in examples/express and examples/fastify.

Terminal window
npm i -D query-profiler

A production install (npm ci --omit=dev) leaves it out, and nothing in production loads it.

The package is only there in development, so import it only if it is installed. See Prisma for what the two calls do.

src/db.ts
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "./generated/prisma/client.js";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
// A dev dependency: a production install leaves it out, and the client stays plain.
const profiler = await import("query-profiler/prisma").catch(() => undefined);
export const prisma: Omit<PrismaClient, "$on"> = profiler
? profiler.profilePrisma(new PrismaClient(profiler.withProfiler({ adapter })))
: new PrismaClient({ adapter });

With a bundled server, plain imports work too: see installation.

One flag for Node, and nothing else:

Terminal window
node --import query-profiler/register src/server.js

It profiles each request from Node itself, before your framework sees it, and serves the profiler. It also gives your app’s own imports of the profiler (query-profiler/prisma in db.ts) its real code. Without it, as in production, every entry is the version that does nothing.

With your watcher:

{
"scripts": {
"dev": "node --watch --import query-profiler/register src/server.js",
"start": "node src/server.js"
}
}

The start script stays as it is: there is nothing to remove before deploying.

Run the dev server. Once your app listens, the terminal says where the profiler is:

[query-profiler] http://localhost:4983/_profiler

It shows every request, live. Port 4983 is the default; set QUERY_PROFILER_PORT to pick another. If the port is busy, the profiler waits a second for a restarting app to let go of it, then takes the next free one and prints it. In a container, set QUERY_PROFILER_HOST=0.0.0.0 and publish the port: see troubleshooting.

Several processes, one profiler. With Node’s cluster, PM2 in cluster mode, or two apps on the same machine, the first process to start serves the profiler, and the others send it their requests:

[query-profiler] http://localhost:4983/_profiler (shared with another process)

If the process that serves it stops (a restart, a worker that dies), another one takes the port over. To keep two apps apart, give each its own QUERY_PROFILER_PORT.

If it is off, the terminal says why: a NODE_ENV other than development, or a Node older than 22.19 or 24.5. Nothing at all, or an empty profiler? See troubleshooting.

  • The page at http://localhost:4983/_profiler: every request, with its queries, operations, timeline and warnings.

  • X-Debug-Token on every response: open /_profiler/<token> for that request.

  • One line per request in the terminal, with QUERY_PROFILER_LOG=1:

    [query-profiler] GET /users 200 · 7 queries · 18.92 ms in db · 7 operations · 1 warning · 80.49 ms · 90dd08
  • The JSON behind the page: see the HTTP API.

Any framework that serves through Node’s http module works the same way. These are tested in CI, with Prisma against PostgreSQL:

Framework Tested with
Express 4.18 and later, 5
Fastify 4.26 and later, 5
Koa 3
Hapi 21
NestJS 12, on Express
Hono 4, on @hono/node-server 2

Before Express 4.18, its body parser loses the request’s async context: a route that reads a body runs outside its profile, and its queries are lost.

HTTP/2 servers (Fastify with http2: true, say) are profiled on Node 22.17+ and 24.2+, where Node publishes their requests too. Their responses carry no X-Debug-Token.

When a request comes from another origin, the preload adds X-Debug-Token to Access-Control-Expose-Headers, so a frontend there can read it. If your CORS settings set that header themselves (exposedHeaders in cors), list X-Debug-Token there too.

Node publishes each request on its http.server.request.start diagnostics channel, right before the server hands it to its handler. The preload opens the request’s profile there, so your framework, and every query it makes, runs inside it. More in how it works.