Server-Timing API 完全ガイド|Express ミドルウェアと Chrome DevTools 連携

(更新: 2026年6月21日 ) パフォーマンス Server-Timing Express Node.js 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 はメトリクス名(任意の識別子)、durduration(ミリ秒) を意味するディレクティブです。クライアント側の 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 に統一するチームが多いです。複数クエリがある場合は次のいずれかを選びます。

  1. 合算db;dur=120.5(全クエリの合計)
  2. 分割db-read;dur=80.2, db-write;dur=40.3
  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-apilegacy-billing)をそのままメトリクス名にすると、本番レスポンスから技術スタックが推測されます。本番では ext1;dur=... のように匿名化するか、ヘッダー自体を出さない運用にしてください。

Chrome DevTools での確認手順

Server-Timing の価値は ブラウザ開発者ツールとの統合 にあります。手順は次のとおりです。

1

Chrome で対象ページを開き、DevTools(F12)→ Network タブを表示する

2

API リクエスト(XHR/fetch)を発行し、該当行をクリックする

3

Timing サブタブを開き、下部の Server Timing セクションを確認する

4

db、cache、total など各メトリクスの duration を読み、ボトルネックを特定する

5

必要に応じて 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 に 下流 HTTPRedis の時間も含めると 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 パターンは可読性が高く、ビジネスステップ単位paymentinventory)でメトリクスを分けやすいです。

OpenTelemetry との併用設計

Server-Timing は ブラウザに届く 1 リクエストの内訳 に特化しています。OpenTelemetry Node.js 実装ガイド で解説している分散トレーシングは、サービス間を横断した Span ツリー と本番サンプリングが目的です。

観点Server-TimingOpenTelemetry
消費者ブラウザ DevToolsJaeger / 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-HeadersServer-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 を入れると漏洩する
  • 攻撃者が繰り返し叩くことで タイミング攻撃 の補助になる可能性がある

対策のチェックリストです。

  1. 本番では無効化enabled: process.env.ENABLE_SERVER_TIMING === 'true' のように明示オプトイン
  2. desc を本番で使わない — 開発環境のみ description を付与
  3. メトリクス名を汎用化db / cache / ext 程度に留める
  4. 認証済みプレビューのみ — 社内 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.writeHeadres.flushHeaders の後では setHeader が効かない場合があります。Server-Timing ミドルウェアは 可能な限り早く チェーンに入れ、ヘッダー付与は res.end 直前にまとめます。

圧縮ミドルウェアとの順序

compression ミドルウェアは通常 res.end をラップします。Server-Timing も res.end をラップするため、登録順序 でどちらが外側になるかが変わります。推奨は serverTimingcompression の順で、計測が圧縮処理時間を含むかどうかをチームで決めておくことです。

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・フォールバック