Server-Timing API 完全ガイド|Express ミドルウェアと Chrome DevTools 連携
- Server-Timing は、サーバー内の処理内訳を
Server-Timing: db;dur=45.2, cache;dur=1.1のように HTTP ヘッダーで返し、Chrome DevTools の Network タブから即座に読める W3C 標準だ。 - Express では リクエストスコープの計測コンテキスト + finish フックで実装し、DB や外部 API のラッパーから
db;durを積み上げる。 - 本番の継続監視は OpenTelemetry 分散トレーシング に任せ、Server-Timing は開発・ステージングでの高速デバッグに限定するのが 2026 年の定石。
- フロント側の体感遅延は クリティカルレンダリングパス最適化 と合わせて、ネットワーク+サーバー+描画の三段階で切り分ける。
Server-Timing API とは
Server-Timing は、サーバーがリクエスト処理中に計測したタイミング情報を、HTTP レスポンスヘッダー Server-Timing としてクライアントに返す W3C 仕様です(Server Timing)。ブラウザはこのヘッダーをパースし、Performance API および DevTools 上で人間が読める形に表示します。
典型的なヘッダーは次のとおりです。
Server-Timing: db;dur=53.2, cache;dur=2.1, auth;dur=8.7, total;dur=71.4
ここで db はメトリクス名(任意の識別子)、dur は duration(ミリ秒) を意味するディレクティブです。クライアント側の JavaScript からは次のように取得できます。
// 同一オリジンのページ遷移後
const [nav] = performance.getEntriesByType('navigation');
if (nav?.serverTiming?.length) {
for (const entry of nav.serverTiming) {
console.log(entry.name, entry.duration, entry.description);
}
}
fetch API で取得したレスポンスに対しては、Resource Timing の serverTiming プロパティから読み取ります(CORS 設定が必要な場合あり)。
Server-Timing の強みは 追加インフラ不要 であることです。Jaeger や Grafana Tempo を立てなくても、開発者が Chrome を開くだけで「この API は DB に何 ms かかっているか」を確認できます。一方、履歴保存・分散横断・アラートには向かないため、本番オブザーバビリティの主軸にはしません。
| 計測手段 | 特徴・向き不向き |
|---|---|
| Server-Timing ヘッダー | DevTools で即確認。インフラ不要。本番での情報漏洩リスクあり。開発・ステージング向き |
| 構造化ログ + durationMs | 全リクエストを Loki 等で検索可能。リアルタイムのブラウザ連携は弱い |
| OpenTelemetry トレーシング | マイクロサービス横断・サンプリング・アラートに強い。セットアップコストあり |
| APM(Datadog 等) | 自動計装とダッシュボードが充実。コストとベンダーロックインが課題 |
ヘッダー形式と db;dur の書き方(2026年版)
W3C 仕様では、1 つの Server-Timing ヘッダーに 複数のメトリクス をカンマ区切りで並べます。各メトリクスは次のディレクティブを持てます。
| ディレクティブ | 意味 | 例 |
|---|---|---|
dur | 処理時間(ミリ秒、小数可) | db;dur=45.23 |
desc | 人間向け説明(引用符で囲む) | db;dur=45;desc="PostgreSQL orders" |
| (名前のみ) | 名前だけのプレースホルダ | cdn |
db;dur 形式の実務ルール
データベースクエリの計測では、メトリクス名を db に統一するチームが多いです。複数クエリがある場合は次のいずれかを選びます。
- 合算 —
db;dur=120.5(全クエリの合計) - 分割 —
db-read;dur=80.2, db-write;dur=40.3 - 最大値+件数(desc) —
db;dur=95.1;desc="queries=7"
2026 年の実務では、合算 + 必要なら db-read / db-write に分ける のがバランス良いです。クエリごとに 20 個のメトリクスを並べるとヘッダーが肥大化し、HTTP/2 のヘッダー圧縮でも無視できないオーバーヘッドになります。
Server-Timing: app;dur=2.1, db;dur=87.4, redis;dur=3.2, upstream;dur=156.0, total;dur=251.9
total はアプリ全体の処理時間(ミドルウェア入口からレスポンス送信直前まで)を示す慣習的な名前です。仕様上必須ではありませんが、DevTools で一目で全体像を把握しやすくなります。
内部サービス名(stripe-api、legacy-billing)をそのままメトリクス名にすると、本番レスポンスから技術スタックが推測されます。本番では ext1;dur=... のように匿名化するか、ヘッダー自体を出さない運用にしてください。
Chrome DevTools での確認手順
Server-Timing の価値は ブラウザ開発者ツールとの統合 にあります。手順は次のとおりです。
Chrome で対象ページを開き、DevTools(F12)→ Network タブを表示する
API リクエスト(XHR/fetch)を発行し、該当行をクリックする
Timing サブタブを開き、下部の Server Timing セクションを確認する
db、cache、total など各メトリクスの duration を読み、ボトルネックを特定する
必要に応じて performance.getEntriesByType('resource') でプログラム取得する
DevTools の Timing パネルでは、DNS・SSL・Waiting (TTFB)・Content Download に加え、Server Timing としてサーバーが返した内訳がリスト表示されます。TTFB が長いのに Server-Timing の db が短い場合、アプリケーション以前(ロードバランサ、WAF、コールドスタート)や レスポンスボディ生成・圧縮 が疑われます。
フロントエンドの体感遅延は、サーバー内訳だけでは説明できません。クリティカルレンダリングパス最適化 で述べているように、TTFB 以降の CSS/JS ブロッキングと LCP が支配的なケースでは、Server-Timing でサーバーが健全でもユーザー体感は遅いままです。パフォーマンス調査は サーバー(Server-Timing)→ 転送(Network)→ 描画(Performance) の順で層を分けてください。
Express ミドルウェアの基本実装
Node.js + Express で Server-Timing を導入する最小構成を示します。リクエストごとに計測値を蓄積し、レスポンス完了時にヘッダーを付与します。
// server-timing.js
const { AsyncLocalStorage } = require('node:async_hooks');
const timingStorage = new AsyncLocalStorage();
function createTimingContext() {
return {
metrics: [],
startHr: process.hrtime.bigint(),
};
}
function recordMetric(name, durationMs, description) {
const ctx = timingStorage.getStore();
if (!ctx) return;
ctx.metrics.push({
name,
durationMs: Math.round(durationMs * 10) / 10,
description,
});
}
function formatMetric({ name, durationMs, description }) {
const parts = [`${name};dur=${durationMs}`];
if (description) {
const safe = String(description).replace(/"/g, "'");
parts.push(`desc="${safe}"`);
}
return parts.join(';');
}
function serverTimingMiddleware(options = {}) {
const { enabled = process.env.NODE_ENV !== 'production' } = options;
return function serverTiming(req, res, next) {
if (!enabled) {
return next();
}
const ctx = createTimingContext();
timingStorage.run(ctx, () => {
const originalEnd = res.end;
res.end = function patchedEnd(...args) {
const totalNs = process.hrtime.bigint() - ctx.startHr;
const totalMs = Number(totalNs) / 1e6;
recordMetric('total', totalMs);
const headerValue = ctx.metrics
.map(formatMetric)
.join(', ');
if (headerValue) {
res.setHeader('Server-Timing', headerValue);
}
return originalEnd.apply(this, args);
};
next();
});
};
}
module.exports = {
serverTimingMiddleware,
recordMetric,
timingStorage,
};
// app.js
const express = require('express');
const { serverTimingMiddleware } = require('./server-timing');
const app = express();
app.use(express.json());
app.use(serverTimingMiddleware({ enabled: true }));
app.get('/health', (req, res) => {
res.json({ ok: true });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Listening on ${PORT}`);
});
res.end をラップする理由は、ストリーミングレスポンスやエラーハンドラを経ても最終送信直前でヘッダーを付けられる ためです。res.send の直前にヘッダーを付ける実装は、途中で res.send が複数回呼ばれた場合や圧縮ミドルウェアとの順序で漏れやすいです。
DB クエリの計測:db;dur の実装
PostgreSQL(pg)を例に、クエリ実行時間を db;dur として記録するラッパーです。
// db-timing.js
const { Pool } = require('pg');
const { recordMetric } = require('./server-timing');
function createTimedPool(connectionString) {
const pool = new Pool({ connectionString });
const originalQuery = pool.query.bind(pool);
pool.query = async function timedQuery(text, params) {
const start = process.hrtime.bigint();
try {
return await originalQuery(text, params);
} finally {
const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
recordMetric('db', elapsedMs);
}
};
return pool;
}
module.exports = { createTimedPool };
// routes/orders.js
const express = require('express');
const { createTimedPool } = require('../db-timing');
const pool = createTimedPool(process.env.DATABASE_URL);
const router = express.Router();
router.get('/orders/:id', async (req, res, next) => {
try {
const { rows } = await pool.query(
'SELECT id, status, total FROM orders WHERE id = $1',
[req.params.id],
);
if (rows.length === 0) {
return res.status(404).json({ error: 'not_found' });
}
res.json(rows[0]);
} catch (err) {
next(err);
}
});
module.exports = router;
同一リクエストで複数クエリが走る場合、上記は 呼び出しごとに db メトリクスを追加 します。仕様上、同じ名前のメトリクスが複数あると DevTools では同名エントリが並びます。合算したい場合はコンテキスト側で加算ロジックに変更します。
function recordMetricSum(name, durationMs) {
const ctx = timingStorage.getStore();
if (!ctx) return;
const existing = ctx.metrics.find((m) => m.name === name);
if (existing) {
existing.durationMs = Math.round((existing.durationMs + durationMs) * 10) / 10;
} else {
ctx.metrics.push({
name,
durationMs: Math.round(durationMs * 10) / 10,
});
}
}
外部 API・キャッシュの計測
マイクロサービス構成では、Server-Timing に 下流 HTTP や Redis の時間も含めると DevTools 上の切り分けが容易になります。
// timed-fetch.js
const { recordMetric } = require('./server-timing');
async function timedFetch(url, init = {}) {
const start = process.hrtime.bigint();
try {
const response = await fetch(url, init);
return response;
} finally {
const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
recordMetric('upstream', elapsedMs);
}
}
module.exports = { timedFetch };
// timed-redis.js
const { recordMetric } = require('./server-timing');
function wrapRedisClient(redis) {
const handlers = {
get: true,
set: true,
del: true,
mget: true,
};
return new Proxy(redis, {
get(target, prop) {
const original = target[prop];
if (typeof original !== 'function' || !handlers[prop]) {
return original;
}
return async function timedRedisCommand(...args) {
const start = process.hrtime.bigint();
try {
return await original.apply(target, args);
} finally {
const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
recordMetric('redis', elapsedMs);
}
};
},
});
}
module.exports = { wrapRedisClient };
// routes/product.js
const express = require('express');
const { recordMetric } = require('../server-timing');
const { timedFetch } = require('../timed-fetch');
const { wrapRedisClient } = require('../timed-redis');
const Redis = require('ioredis');
const redis = wrapRedisClient(new Redis(process.env.REDIS_URL));
const router = express.Router();
router.get('/products/:id', async (req, res, next) => {
try {
const cacheKey = `product:${req.params.id}`;
const cached = await redis.get(cacheKey);
if (cached) {
recordMetric('cache', 0.5);
return res.json(JSON.parse(cached));
}
const upstream = await timedFetch(
`https://catalog.internal.example/items/${req.params.id}`,
{ headers: { Accept: 'application/json' } },
);
if (!upstream.ok) {
return res.status(502).json({ error: 'catalog_unavailable' });
}
const data = await upstream.json();
await redis.set(cacheKey, JSON.stringify(data), 'EX', 300);
res.json(data);
} catch (err) {
next(err);
}
});
module.exports = router;
cache に極小の固定値を入れるのは、キャッシュヒット経路を DevTools 上で識別する ためのテクニックです(実際の Redis GET 時間は redis;dur に入ります)。
TypeScript 版:型安全な Server-Timing ユーティリティ
大規模コードベースでは TypeScript で計測 API を型付けします。
// server-timing.ts
import { AsyncLocalStorage } from 'node:async_hooks';
import type { RequestHandler } from 'express';
interface TimingMetric {
name: string;
durationMs: number;
description?: string;
}
interface TimingContext {
metrics: TimingMetric[];
startHr: bigint;
}
const timingStorage = new AsyncLocalStorage<TimingContext>();
export function recordMetric(
name: string,
durationMs: number,
description?: string,
): void {
const ctx = timingStorage.getStore();
if (!ctx) return;
ctx.metrics.push({
name,
durationMs: Math.round(durationMs * 10) / 10,
description,
});
}
export async function measureAsync<T>(
name: string,
fn: () => Promise<T>,
): Promise<T> {
const start = process.hrtime.bigint();
try {
return await fn();
} finally {
const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
recordMetric(name, elapsedMs);
}
}
function formatMetric(metric: TimingMetric): string {
const base = `${metric.name};dur=${metric.durationMs}`;
if (!metric.description) return base;
const safe = metric.description.replace(/"/g, "'");
return `${base};desc="${safe}"`;
}
export interface ServerTimingOptions {
enabled?: boolean;
}
export function serverTimingMiddleware(
options: ServerTimingOptions = {},
): RequestHandler {
const enabled = options.enabled ?? process.env.NODE_ENV !== 'production';
return (req, res, next) => {
if (!enabled) {
next();
return;
}
const ctx: TimingContext = {
metrics: [],
startHr: process.hrtime.bigint(),
};
timingStorage.run(ctx, () => {
const originalEnd = res.end.bind(res);
res.end = ((chunk?: unknown, encoding?: BufferEncoding, cb?: () => void) => {
const totalMs =
Number(process.hrtime.bigint() - ctx.startHr) / 1e6;
recordMetric('total', totalMs);
const value = ctx.metrics.map(formatMetric).join(', ');
if (value) {
res.setHeader('Server-Timing', value);
}
return originalEnd(chunk, encoding as BufferEncoding, cb);
}) as typeof res.end;
next();
});
};
}
// routes/checkout.ts
import { Router } from 'express';
import { measureAsync } from '../server-timing';
import { chargePayment } from '../services/payment';
import { createOrder } from '../services/orders';
const router = Router();
router.post('/checkout', async (req, res, next) => {
try {
const order = await measureAsync('db', () =>
createOrder(req.body),
);
await measureAsync('payment', () =>
chargePayment(order.id, req.body.paymentToken),
);
res.status(201).json({ orderId: order.id });
} catch (err) {
next(err);
}
});
export default router;
measureAsync パターンは可読性が高く、ビジネスステップ単位(payment、inventory)でメトリクスを分けやすいです。
OpenTelemetry との併用設計
Server-Timing は ブラウザに届く 1 リクエストの内訳 に特化しています。OpenTelemetry Node.js 実装ガイド で解説している分散トレーシングは、サービス間を横断した Span ツリー と本番サンプリングが目的です。
| 観点 | Server-Timing | OpenTelemetry |
|---|---|---|
| 消費者 | ブラウザ DevTools | Jaeger / Tempo / Datadog |
| スコープ | 単一 HTTP レスポンス | 分散システム全体 |
| 永続化 | なし(その場のデバッグ) | トレースバックエンドに保存 |
| 導入コスト | ミドルウェア数十行 | SDK + Collector + バックエンド |
併用パターンとして、計測ロジックを 1 か所に集約し、開発時は Server-Timing に、常時は Span 属性に 同じ duration を流す方法があります。
// unified-timing.js
const { trace } = require('@opentelemetry/api');
const { recordMetric } = require('./server-timing');
function observeDuration(name, durationMs, attributes = {}) {
recordMetric(name, durationMs);
const span = trace.getActiveSpan();
if (span) {
span.setAttribute(`timing.${name}.ms`, durationMs);
for (const [key, value] of Object.entries(attributes)) {
span.setAttribute(`timing.${name}.${key}`, value);
}
}
}
module.exports = { observeDuration };
本番では Server-Timing をオフにしても、OpenTelemetry 側に timing.db.ms が残るため、障害調査の導線は維持されます。
CORS と Timing-Allow-Origin
クロスオリジン の fetch で response.serverTiming や DevTools の Server Timing を読むには、サーバーが次のヘッダーを返す必要があります。
Timing-Allow-Origin: https://app.example.com
または
Timing-Allow-Origin: *
Access-Control-Expose-Headers に Server-Timing を含める必要は ありません(Server-Timing は Timing Allow Origin で制御される別系統です)。ただしカスタムヘッダーを JS から読む場合は expose の設定を確認してください。
// cors-timing.js
function corsWithTiming(req, res, next) {
const allowedOrigin = process.env.WEB_ORIGIN || 'http://localhost:5173';
res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Timing-Allow-Origin', allowedOrigin);
next();
}
module.exports = { corsWithTiming };
BFF が別ドメインの SPA から呼ばれる構成では、この設定を忘れると サーバーはヘッダーを返しているのにクライアント JS では見えない という混乱が起きがちです。
セキュリティとプライバシー
Server-Timing は便利な反面、情報開示 のベクトルになります。
- メトリクス名から内部コンポーネント名が推測できる
descに SQL 断片やユーザー ID を入れると漏洩する- 攻撃者が繰り返し叩くことで タイミング攻撃 の補助になる可能性がある
対策のチェックリストです。
- 本番では無効化 —
enabled: process.env.ENABLE_SERVER_TIMING === 'true'のように明示オプトイン - desc を本番で使わない — 開発環境のみ description を付与
- メトリクス名を汎用化 —
db/cache/ext程度に留める - 認証済みプレビューのみ — 社内 VPN や Basic 認証配下のステージングだけ有効化
セキュリティヘッダー全般の文脈は セキュリティヘッダー一覧と設定 も参照してください。Server-Timing はセキュリティヘッダーではありませんが、レスポンスに載せる情報の最小化 という同じ原則が適用されます。
テストと CI での検証
ヘッダーが期待どおり返るかは、統合テストで自動化できます。
// server-timing.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const request = require('supertest');
const express = require('express');
const { serverTimingMiddleware, recordMetric } = require('./server-timing');
test('Server-Timing header includes db and total', async () => {
const app = express();
app.use(serverTimingMiddleware({ enabled: true }));
app.get('/demo', (req, res) => {
recordMetric('db', 42.5);
res.send('ok');
});
const res = await request(app).get('/demo');
assert.equal(res.status, 200);
assert.match(res.headers['server-timing'], /db;dur=42\.5/);
assert.match(res.headers['server-timing'], /total;dur=/);
});
// parse-server-timing.test.js
function parseServerTiming(header) {
if (!header) return [];
return header.split(',').map((part) => {
const trimmed = part.trim();
const name = trimmed.split(';')[0];
const durMatch = trimmed.match(/dur=([0-9.]+)/);
const descMatch = trimmed.match(/desc="([^"]*)"/);
return {
name,
durationMs: durMatch ? Number(durMatch[1]) : 0,
description: descMatch ? descMatch[1] : undefined,
};
});
}
test('parseServerTiming handles multiple metrics', () => {
const parsed = parseServerTiming(
'db;dur=45.2, cache;dur=1.1, total;dur=50.0',
);
assert.equal(parsed.length, 3);
assert.equal(parsed[0].name, 'db');
assert.equal(parsed[0].durationMs, 45.2);
});
CI ではステージングデプロイ後に 合成監視 で Server-Timing の存在をチェックし、リグレッションで計測が消えたらアラートする運用も可能です(本番ではヘッダー非公開でも、ステージングでパイプラインを検証)。
よくある落とし穴
ヘッダーが付く前にレスポンスが送られる
res.writeHead や res.flushHeaders の後では setHeader が効かない場合があります。Server-Timing ミドルウェアは 可能な限り早く チェーンに入れ、ヘッダー付与は res.end 直前にまとめます。
圧縮ミドルウェアとの順序
compression ミドルウェアは通常 res.end をラップします。Server-Timing も res.end をラップするため、登録順序 でどちらが外側になるかが変わります。推奨は serverTiming → compression の順で、計測が圧縮処理時間を含むかどうかをチームで決めておくことです。
Server-Timing と Duration ヘッダーの混同
Server-Timing は処理内訳、Duration は HTTP/1.0 由来のレガシーヘッダーで現代ではほぼ使われません。混同しないでください。
マイクロ秒とミリ秒
dur は 常にミリ秒 です。process.hrtime.bigint() はナノ秒なので 1e6 で割る必要があります。誤って 1e3 で割ると 1000 倍ズレます。
Nginx / CDN 経由での注意点
リバースプロキシがレスポンスヘッダーを strip しないか確認します。Nginx の proxy_pass_header Server-Timing; は通常不要(デフォルトで通過)ですが、proxy_hide_header 設定があると消えます。
CDN(Cloudflare 等)では Workers や Transform Rules でヘッダーを追加・削除できます。エッジキャッシュ HIT 時はオリジンに届かないため、Server-Timing は キャッシュミス時や動的 API に限定される点を理解しておいてください。
まとめ:いつ Server-Timing を使うか
Server-Timing API は、Express ミドルウェアと db;dur 形式のヘッダー で、サーバー処理の内訳を Chrome DevTools に直接届ける軽量な計測手段です。開発中に「この画面の API、DB が遅いのかそれともフロントか」を秒速で切り分けられます。
| シナリオ | 推奨 |
|---|---|
| ローカル・PR プレビューでのデバッグ | Server-Timing を有効化 |
| 本番 SLA 監視・アラート | OpenTelemetry + メトリクス |
| フロントの LCP / FCP 改善 | CRP 最適化 |
| ログベースの事後分析 | 構造化ログ + requestId |
2026 年のバックエンド性能計測は、Server-Timing(開発の速さ)+ OpenTelemetry(本番の深さ)+ Core Web Vitals(ユーザー体感) の三層で設計するのが実務的です。まずは本記事の Express ミドルウェアをコピーし、DevTools の Network タブで db;dur が見えることを確認するところから始めてください。
関連記事
この記事は Webパフォーマンス テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| HTTP/2とHTTP/3の違い | HTTP/2とHTTP/3の違い|QUICがWeb表示速度を変える理由 |
| Speculation Rules API 完全ガイド | Speculation Rules API 完全ガイド|prefetch・prerender・Chrome 144 prerender_until_script |
| fetchpriority属性でLCP画像を最適化する | fetchpriority属性でLCP画像を最適化する|2026年実践ガイド |
| Web Vitals INP改善ガイド | Web Vitals INP改善ガイド|scheduler.yield・Long Task・Input Delay対策(2026) |
| content-visibility: auto 完全ガイド | content-visibility: auto 完全ガイド|遅延レンダリングと長尺ページ最適化 2026 |
| Brotli vs Gzip 圧縮比較 | Brotli vs Gzip 圧縮比較|nginx静的・動的設定とCDN実践ガイド |
| CSS/JSを圧縮(Minify)するメリット | CSS/JSを圧縮(Minify)するメリット|サイト高速化の基本 |
| Service Worker キャッシュ戦略ガイド | Service Worker キャッシュ戦略ガイド|PWA の cache-first・network-first・SWR・Workbox 設計 |
| View Transitions API 完全ガイド | View Transitions API 完全ガイド|SPA・MPA ページ遷移と Astro/React 統合 |
| WebP変換で画質が落ちる原因と最適な設定 | WebP変換で画質が落ちる原因と最適な設定 |
| 画像圧縮とWebP変換 | 画像圧縮とWebP変換|PageSpeed改善の基本 |
| Subresource Integrity (SRI) 完全ガイド | Subresource Integrity (SRI) 完全ガイド|CDN スクリプトの integrity・crossorigin・フォールバック |