When trying to share common prom-client metrics (e.g., Counter) across both the App Router and Pages Router in a Next.js application, several challenges arise. Problems particularly occur when attempting to register a metrics registry on the global object.
The Problem
In a Next.js project like a T3 Stack, I considered sharing a prom-client Counter through a Logger class for log metrics. Specifically, I tried registering prom-client’s default registry on the global object to use a single metrics instance across the entire application.
However, with this approach, a build error occurred when trying to register a Counter to the global registry from a Pages Router API route.
import { Counter } from 'prom-client';
class Logger {
private static instance: Logger;
private errorCounter: Counter<string>;
private warnCounter: Counter<string>;
private constructor() {
// Problems occur when trying to initialize Counters and register them with the default registry here
this.errorCounter = new Counter({
name: 'errors_total',
help: 'Total number of errors',
});
this.warnCounter = new Counter({
name: 'warnings_total',
help: 'Total number of warnings',
});
// prom-client's default registry is managed globally, but
// in the Next.js environment, App Router and Pages Router have different contexts,
// so sharing the global object may not work as expected.
}
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
// ... logging methods ...
}
Workaround and Remaining Issues
To work around this problem, I tried having the Logger class hold its own prom-client register instance.
import { Counter, register } from 'prom-client';
class Logger {
private static instance: Logger;
private errorCounter: Counter<string>;
private warnCounter: Counter<string>;
private registerInstance: typeof register; // Hold the register instance
private constructor() {
this.registerInstance = register; // Get the default registry instance
this.errorCounter = new Counter({
name: 'errors_total',
help: 'Total number of errors',
registers: [this.registerInstance], // Register with this registry
});
this.warnCounter = new Counter({
name: 'warnings_total',
help: 'Total number of warnings',
registers: [this.registerInstance], // Register with this registry
});
}
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
// Method to register metrics (if needed)
public registerMetric(metric: Counter<string>) {
this.registerInstance.registerMetric(metric);
}
// ... logging methods ...
}
export default Logger;
This approach resolved the build error, but the fundamental problem was not solved. The App Router and Pages Router API routes appear to run in different JavaScript contexts within Next.js’s build and runtime environment. Therefore, even when trying to share a register instance, separate prom-client registry instances are actually created, making it impossible to truly share metrics.
This issue was also mentioned in Next.js GitHub Discussions, and no clear solution had been provided as of November 2023.
The rest of this article works out the precise mechanism behind this behavior, and reports what was actually verified by reproducing it.
Root cause: why the modules end up isolated
prom-client’s register (the default registry) is a singleton held in the module scope of the prom-client package. Node’s CommonJS/ESM module system evaluates a given resolved module path exactly once and returns the cached module object for every subsequent require/import. It is this “one instance per module” guarantee that makes new Counter(...) calls from different files converge on the same register under normal Node.js usage.
However, Next.js’s build systems (webpack and Turbopack) generate an independent module graph (bundle) per route handler. An App Router route.ts and a Pages Router pages/api/*.ts file are compiled into separate chunks with separate execution contexts, even when both import the same lib/metrics.ts. As a consequence, the prom-client module itself is evaluated once per bundle, and one Registry instance is created per bundle — not one per process. This is the actual mechanism behind the “metrics aren’t shared” problem observed in 2023: re-registering onto the global object by hand does not help, because the prom-client module instance that reads global is itself different in each bundle.
This same structure produces a second, distinct symptom in development mode. Fast Refresh (webpack HMR / Turbopack’s incremental recompilation) re-evaluates only the changed file and its dependents; unchanged dependencies (such as node_modules/prom-client) keep using their already-evaluated, cached module instance. Concretely, within a single bundle:
- On the first request,
lib/metrics.tsis evaluated andnew Counter({ name: 'errors_total' })registers onto that bundle’sregister(created exactly once so far). - When a developer edits
lib/metrics.ts, Fast Refresh re-evaluates only that file. Theprom-clientmodule itself was not changed, so it is not re-evaluated — itsregistersurvives, still holding a registration forerrors_total. - The re-evaluated
lib/metrics.tsrunsnew Counter({ name: 'errors_total' })again, attempting to register the same name onto the same still-aliveregister, which throwsError: A metric with the name errors_total has already been registered.
In other words, the “duplicate registration” error is not caused by module isolation per se, but by an asymmetry within a single bundle: the file that defines the metric gets re-evaluated while the file holding the registry does not. The App Router/Pages Router split (the 2023-era problem above) and the Fast Refresh duplicate-registration error (verified below) sit on the same axis — how far module evaluation is isolated — and are two adjacent symptoms of it.
Diagram

Left: each bundle has its own isolated module scope (Registry A and Registry B are different instances). Re-evaluating metrics.js collides with the still-alive registry inside the same bundle. Right: caching the Counter on globalThis per process and reusing the existing instance on re-evaluation prevents the duplicate-registration throw (this still does not share state across bundles).
Verification: what was checked, at which layer
Rather than stopping at a plausible-sounding explanation, this was actually verified — at two clearly distinct layers.
Layer 1: a Node.js mechanical analog (module re-evaluation itself)
Without starting Next.js at all, the module re-evaluation asymmetry was reproduced by manually manipulating Node’s require.cache: invalidating only the file that defines the metric while leaving prom-client’s own cached module untouched. This is explicitly a mechanical analog of Next.js’s behavior, not a reproduction of Next.js itself.
// The metric-defining file (the one Fast Refresh re-evaluates)
const { Counter, register } = require('prom-client');
const errorCounter = new Counter({
name: 'errors_total',
help: 'Total number of errors',
registers: [register],
});
module.exports = { errorCounter, register };
const metricsPath = require.resolve('./metrics.js');
// First evaluation (analogous to the initial compile)
const first = require(metricsPath);
// Fast Refresh analog: invalidate only metrics.js from the cache.
// prom-client's own cache entries are deliberately left alone
// (modeling how a stable dependency is not re-evaluated by HMR).
delete require.cache[metricsPath];
try {
require(metricsPath); // re-runs new Counter(...)
} catch (err) {
console.log(err.name + ':', err.message);
}
Result:
$ node repro_duplicate.js
Error: A metric with the name errors_total has already been registered.
This was run against prom-client@15.1.3 (the current release at verification time). Repeating the same experiment but also deleting the entire prom-client module subtree from the cache (modeling a fully independent module graph) produced no error — just two separate register instances. Note this requires clearing prom-client’s internal submodules too, not just its entry-point file: the global registry singleton actually lives in prom-client/lib/registry.js, so deleting only the top-level prom-client cache entry while that submodule stays cached still throws. That models bundle-to-bundle isolation (App Router/Pages Router, or separate serverless function instances), covered under “Edge cases” below.
Layer 2: a real reproduction on actual Next.js (Turbopack)
Beyond the analog, a minimal Next.js project was created from scratch and next dev (Next.js 16.2.10, Turbopack) was actually started, with both an App Router and a Pages Router route handler importing the same lib/metrics.js.
import { errorCounter, register } from '../../../lib/metrics.js';
export async function GET() {
errorCounter.inc();
const body = await register.metrics();
return new Response(body, { headers: { 'Content-Type': register.contentType } });
}
import { errorCounter, register } from '../../lib/metrics.js';
export default async function handler(req, res) {
errorCounter.inc();
const body = await register.metrics();
res.setHeader('Content-Type', register.contentType);
res.status(200).send(body);
}
Hitting both endpoints repeatedly first showed each one incrementing independently (errors_total went 1, 2, 3, ... separately per route). This confirms, live on Next.js 16, the 2023-era problem: App Router and Pages Router hold separate Registry instances.
Next, with the dev server still running, a single comment line was appended to lib/metrics.js to force a Fast Refresh recompilation, and both endpoints were hit again. Both routes actually threw the following error (unedited excerpt from the server log):
⨯ Error: A metric with the name errors_total has already been registered.
at Module.eval (lib/metrics.js:6:29)
at module evaluation (app/api/app-metrics/route.js:1:1)
...
at new Counter (node_modules/prom-client/lib/counter.js:20:3)
GET /api/app-metrics 500 in 80ms
This matches the Layer-1 Node.js analog exactly: the bundle holding the registry re-evaluated only the unchanged lib/metrics.js file and attempted a duplicate registration against its still-alive Registry. This was confirmed to occur on an actual Next.js dev server, not just in the analog. Once in this state, the affected route kept returning errors on every subsequent request until the process was restarted.
Finally, next build && next start was used to start a production build, and the same endpoints were hit repeatedly. No error occurred at all; both App Router and Pages Router kept incrementing correctly (there is no Fast Refresh in production, so each module is evaluated exactly once at startup). This distinction is discussed in the edge cases below.
Implementing and verifying the fix
Guarding with register.getSingleMetric() and caching the instance on globalThis makes the code resilient to module re-evaluation within the same process.
import { Counter, register } from 'prom-client';
declare global {
// eslint-disable-next-line no-var
var __errorCounter: Counter<string> | undefined;
}
function getOrCreateCounter(): Counter<string> {
const existing = register.getSingleMetric('errors_total') as Counter<string> | undefined;
if (existing) return existing;
return new Counter({
name: 'errors_total',
help: 'Total number of errors',
registers: [register],
});
}
export const errorCounter = globalThis.__errorCounter ?? getOrCreateCounter();
globalThis.__errorCounter = errorCounter;
export { register };
Running the Layer-1 Node.js analog against this implementation — deleting and re-requiring the module from require.cache three times in a row — threw no errors, errorCounter === the previous errorCounter was true on every reload, and the accumulated inc() count survived across re-evaluations (final value 4 after four inc() calls total).
$ node repro_fixed.js
no throw; same Counter instance reused? true (x3)
final errors_total value (should be 4: survives all reloads): 4
Edge cases
(a) Dev-mode Fast Refresh vs. production builds and serverless cold starts
As verified above, this “duplicate registration” error is specific to development mode. Fast Refresh repeatedly re-evaluates changed files, repeatedly exposing the asymmetry between module-scope state (prom-client’s register) and the re-evaluated code (the metric definition). In a production build (next build && next start), modules are evaluated exactly once at process startup, so this error does not occur even without any guard.
On the other hand, in serverless/edge environments such as Vercel, a different function instance (cold start) can handle any given request, and each instance owns its own module scope — so register is separate per instance too. This models the same situation as the Layer-1 analog where prom-client’s own cache was also cleared: no error is thrown, but metrics simply aren’t shared across instances. This is not a bug — it is the correct, expected behavior of the serverless execution model. Trying to force sharing across instances anyway (via globalThis, or ad hoc synchronization to external storage) tends to fight the platform rather than fix anything. If aggregation across instances is genuinely required, an external aggregator such as Prometheus Pushgateway is the better-suited tool, discussed in the conclusion.
(b) The register.clear() gotcha
A common workaround for the duplicate-registration error is to call register.clear() unconditionally right before defining metrics. This does make the registration error go away, but it introduces a different problem: it wipes accumulated counter values that should have persisted across requests, not just the metric definitions. Verifying this directly:
function defineAndBumpNaively() {
register.clear(); // resets every metric's value each time this runs
const c = new Counter({
name: "errors_total",
help: "Total number of errors",
});
return c;
}
let counter = defineAndBumpNaively();
counter.inc();
counter.inc(); // errors_total = 2 so far
counter = defineAndBumpNaively(); // clear() runs again, the 2 is gone
counter.inc(); // errors_total = 1 (should have been 3)
value after 2 incs: 2
value after clear()+1 inc (expected 3 if counters should persist, but clear() reset it): 1
Placing register.clear() anywhere that runs on every module evaluation (e.g., top-level file scope) means it gets called far more often than intended. If it is used at all, it should be confined to a place guaranteed to run exactly once at process startup (such as the register() hook in instrumentation.ts, below) — an existence check via getSingleMetric is the safer default.
Recent developments (verified as of July 2026)
- Verified against
prom-clientv15.1.3.register.getSingleMetric(name)is a public API for retrieving an already-registered metric. - Next.js’s
instrumentation.ts(register()hook) has been auto-detected since Next.js 15 without needingexperimental.instrumentationHook, and is recommended over module-scope initialization as the place for setup code that must run exactly once at process startup. It does not run on the Edge Runtime, so it needs aprocess.env.NEXT_RUNTIME === 'nodejs'guard. - Duplicate registration of module-level state under Fast Refresh is not specific to
prom-client— it is a known, longstanding issue dating back to the webpack-HMR era ( siimon/prom-client#196 ). The same asymmetry (only the changed file is re-evaluated; stable dependencies are not) persists after the move to Turbopack, and the reproduction steps in this article were confirmed to hold on Turbopack (Next.js 16.2.10, thenext devdefault) as well.
Conclusion
Fully sharing prom-client metrics between Next.js’s App Router and Pages Router API routes remains difficult. This stems from Next.js’s build system generating an independent module graph per route, which is not resolved by going through global/globalThis. On top of that, development-mode Fast Refresh introduces a distinct symptom — the “duplicate registration” error — caused by a timing mismatch between when the metric-defining file and the registry itself get re-evaluated. Both were reproduced and confirmed separately on an actual Next.js 16.2.10 (Turbopack) setup.
As a fix, combining a register.getSingleMetric() guard with a globalThis cache eliminates the duplicate-registration error within a single process (it does not achieve true sharing across bundles or instances). Where genuine cross-bundle sharing is required, consider either collecting metrics independently per router or aggregating them through an external service such as Prometheus Pushgateway.