Publish CloudWatch Custom Metrics With AWS SDK v3 PutMetricData

A monitor showing line graphs and bar charts on a dark dashboard

Photo by Stephen Dawson on Unsplash

To publish custom metrics with CloudWatch PutMetricData and AWS SDK v3, create a CloudWatchClient and send a PutMetricDataCommand with a Namespace and up to 1,000 MetricData items, each with a MetricName, up to 30 Dimensions, a Unit and either one Value or up to 150 Values with matching Counts. Buffer and send once a minute, not per request.

This guide is for Node.js and TypeScript developers who want business and application metrics in CloudWatch: orders placed, checkout latency, queue lag, anything AWS doesn’t measure for you. You’ll end up with a small typed module that covers the CloudWatch PutMetricData AWS SDK v3 calls a service needs, a clear idea of what each metric costs, and an alarm on the result.

If you publish events rather than numbers, the guide to send events to EventBridge with AWS SDK v3 uses the same buffering and batching ideas for PutEvents.

Prerequisites

What goes into a PutMetricData request?

Field Rule
Namespace Required, 1 to 255 characters. Don’t start it with AWS/; use something like MyApp/Checkout.
MetricData Up to 1,000 metrics per request, and the request must stay under 1 MB.
MetricName Required, up to 255 characters.
Dimensions Up to 30 name/value pairs. Every distinct combination is a separate metric.
Value or Values + Counts One number, or up to 150 unique values with how often each occurred. Range -2360 to 2360; NaN and Infinity are rejected.
StatisticValues A pre-aggregated Sum, Minimum, Maximum and SampleCount. Cheap, but it loses percentiles.
Unit Milliseconds, Count, Bytes, Percent and so on; None if omitted. Data with different units is aggregated separately.
StorageResolution 60 (default, standard) or 1 (high resolution, per-second storage).
Timestamp Up to 2 weeks in the past and 2 hours in the future; the time received if omitted.

A new metric can take up to 15 minutes to appear in ListMetrics and the console’s metric browser, although statistics are usually retrievable within a couple of minutes. Metrics can’t be deleted; they expire 15 months after the last data point. A retired metric leaves blank widgets behind; the script to delete unused CloudWatch dashboards finds dashboards whose metrics have stopped reporting.

How to use CloudWatch PutMetricData in AWS SDK v3, step by step

  1. Create one client per processA module-level CloudWatchClient in the Region where you want to read the metrics. Metrics exist only in the Region they’re published to.
  2. Pick names and a small set of dimensionsDimensions such as Service, Stage and Operation have a handful of values each. Never a user ID, request ID or order ID.
  3. Record in memoryCount each value’s occurrences, so 10,000 latency samples with 300 distinct values become two data objects instead of 10,000 calls.
  4. Flush on a timerEvery 60 seconds for standard resolution, and once more when the process shuts down.
  5. Send up to 1,000 metrics per requestSplit larger buffers into several PutMetricDataCommand calls.
  6. Alarm on the resultPutMetricAlarmCommand with the exact dimension set you publish.

Example: a buffered metrics module

metrics.ts

// metrics.ts
// Publishing custom CloudWatch metrics with AWS SDK for JavaScript v3: counters and latency distributions
// buffered in memory, sent as Values/Counts arrays, in batches of up to 1,000 metrics per PutMetricData request.
import {
  CloudWatchClient,
  PutMetricDataCommand,
  type Dimension,
  type MetricDatum,
  type StandardUnit,
} from "@aws-sdk/client-cloudwatch";

export const cloudwatch = new CloudWatchClient({}); // region from AWS_REGION or your profile

const MAX_METRICS_PER_REQUEST = 1000;
const MAX_VALUES_PER_DATUM = 150;

interface Series {
  name: string;
  unit: StandardUnit;
  dimensions: Dimension[];
  highResolution: boolean;
  counts: Map<number, number>; // value -> number of times it occurred
}

export class MetricsBuffer {
  private readonly series = new Map<string, Series>();
  private readonly namespace: string;
  private readonly defaults: Record<string, string>;

  constructor(namespace: string, defaults: Record<string, string> = {}) {
    if (namespace.startsWith("AWS/")) throw new Error("Custom namespaces must not start with AWS/");
    this.namespace = namespace;
    this.defaults = defaults; // dimensions added to every metric, e.g. Service and Stage
  }

  /** Records one observation. Every distinct name + dimensions combination is a separate, billed metric. */
  record(name: string, value: number, unit: StandardUnit, dims: Record<string, string> = {}, highResolution = false): void {
    if (!Number.isFinite(value)) return; // NaN and Infinity are rejected by CloudWatch
    const all = { ...this.defaults, ...dims };
    const dimensions = Object.keys(all).sort().map((Name) => ({ Name, Value: all[Name] ?? "" }));
    const key = `${name}|${unit}|${highResolution}|${JSON.stringify(dimensions)}`;
    const s = this.series.get(key) ?? { name, unit, dimensions, highResolution, counts: new Map<number, number>() };
    s.counts.set(value, (s.counts.get(value) ?? 0) + 1);
    this.series.set(key, s);
  }

  count(name: string, dims: Record<string, string> = {}, n = 1): void {
    this.record(name, n, "Count", dims);
  }

  /** Turns the buffer into MetricDatum objects of at most 150 unique values each. */
  private drain(): MetricDatum[] {
    const now = new Date();
    const data: MetricDatum[] = [];
    for (const s of this.series.values()) {
      const entries = [...s.counts.entries()];
      for (let i = 0; i < entries.length; i += MAX_VALUES_PER_DATUM) {
        const chunk = entries.slice(i, i + MAX_VALUES_PER_DATUM);
        data.push({
          MetricName: s.name,
          Dimensions: s.dimensions,
          Unit: s.unit,
          Timestamp: now,
          StorageResolution: s.highResolution ? 1 : 60,
          Values: chunk.map(([v]) => v),
          Counts: chunk.map(([, c]) => c),
        });
      }
    }
    this.series.clear();
    return data;
  }

  /** Sends everything buffered. Call it on a timer (e.g. every 60 s) and before the process exits. */
  async flush(): Promise<number> {
    const data = this.drain();
    for (let i = 0; i < data.length; i += MAX_METRICS_PER_REQUEST) {
      await cloudwatch.send(new PutMetricDataCommand({
        Namespace: this.namespace,
        MetricData: data.slice(i, i + MAX_METRICS_PER_REQUEST),
      }));
    }
    return data.length;
  }
}

Using it from a script that simulates 200 checkout requests:

run-metrics.ts

// run-metrics.ts
// Usage: AWS_REGION=us-east-1 npx tsx run-metrics.ts
import { setTimeout as sleep } from "node:timers/promises";
import { MetricsBuffer } from "./metrics.js";

const metrics = new MetricsBuffer("MyApp/Checkout", { Service: "checkout", Stage: process.env.STAGE ?? "dev" });

// Simulate 200 requests: latency in ms, a count per outcome.
for (let n = 0; n < 200; n++) {
  const latency = Math.round(40 + Math.random() * 60 + (n % 50 === 0 ? 400 : 0));
  const failed = n % 40 === 0;
  metrics.record("Latency", latency, "Milliseconds", { Operation: "PlaceOrder" });
  metrics.count(failed ? "OrdersFailed" : "OrdersPlaced", { Operation: "PlaceOrder" });
  await sleep(5);
}

const sent = await metrics.flush();
console.log(`sent ${sent} metric data objects to MyApp/Checkout`);
Terminal

npm install @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript @types/node
npm pkg set type=module

AWS_PROFILE=dev AWS_REGION=us-east-1 npx tsx run-metrics.ts
# sent 3 metric data objects to MyApp/Checkout

Two hundred observations become three data objects: Latency with its distinct values and their counts, OrdersPlaced and OrdersFailed. That’s one request. In a long-running service, call flush() from a setInterval of 60 seconds and from your shutdown handler. A CloudWatch count is for monitoring, not a total your application reads back; for a stored counter, use an atomic increment as in the guide to update DynamoDB items with AWS SDK v3 using conditions and counters.

Because each Latency datum carries raw values, CloudWatch can compute percentiles such as p99. Publishing a StatisticValues set would be smaller, but CloudWatch can only derive percentiles from it when every sample in the set was identical. Percentiles also aren’t available if any value is negative.

Request size: in the current SDK, PutMetricDataCommand gzip-compresses request bodies of 10,240 bytes or more by default. You can change that with the client options requestMinCompressionSizeBytes and disableRequestCompression.

How much do custom metrics cost?

US East (N. Virginia) list prices from the AWS Price List as of September 2026. Metric charges are prorated by the hour and only accrue in hours when you send data.

Item Price
Custom metrics, first 10,000 $0.30 per metric per month
Next 240,000 / next 750,000 / over 1,000,000 $0.10 / $0.05 / $0.02 per metric per month
PutMetricData requests $0.01 per 1,000
Alarms $0.10 per standard-resolution alarm metric, $0.30 for high resolution
Free tier 10 custom or detailed-monitoring metrics, 1 million API requests, 10 standard alarm metrics

A worked example: 3 metric names × 5 operations × 2 stages = 30 metrics, or 30 × $0.30 = $9.00 a month. Ten processes flushing once a minute send 10 × 60 × 730 = 438,000 requests, or $4.38 a month before the free tier. Now add a CustomerId dimension with 5,000 customers: 30 × 5,000 = 150,000 metrics, billed as 10,000 × $0.30 + 140,000 × $0.10 = $17,000 a month. The Prometheus documentation’s naming and labeling guidance gives the same warning for labels: every unique combination is a new series. The example to get this month’s CloudWatch cost with Cost Explorer shows whether it’s already happening.

Should you use Embedded Metric Format in Lambda instead?

In Lambda, a synchronous PutMetricData call adds latency to every invocation. The alternative is the CloudWatch Embedded Metric Format (EMF): write one JSON line to stdout, and CloudWatch Logs extracts the metrics from it asynchronously.

emf-handler.ts

// emf-handler.ts
// Lambda alternative: write an Embedded Metric Format line to stdout and let CloudWatch Logs extract the metric.
// No PutMetricData call and no cloudwatch:PutMetricData permission; you pay for log ingestion instead.
export const handler = async (event: { orderId?: string }): Promise<{ ok: boolean }> => {
  const started = Date.now();
  // ... place the order ...
  console.log(JSON.stringify({
    _aws: {
      Timestamp: Date.now(),
      CloudWatchMetrics: [{
        Namespace: "MyApp/Checkout",
        Dimensions: [["Service", "Operation"]],
        Metrics: [{ Name: "Latency", Unit: "Milliseconds" }, { Name: "OrdersPlaced", Unit: "Count" }],
      }],
    },
    Service: "checkout",
    Operation: "PlaceOrder",
    Latency: Date.now() - started,
    OrdersPlaced: 1,
    orderId: event.orderId, // extra fields stay searchable in Logs Insights but don't become dimensions
  }));
  return { ok: true };
};

The rules: _aws.Timestamp in epoch milliseconds, at most 100 metrics per directive, at most 30 keys per dimension set, and at most 100 values in a metric array. Every dimension set creates metrics just like PutMetricData, so the cardinality warning still applies. The trade-off is cost: EMF lines are billed as log ingestion at $0.50 per GB in us-east-1 (September 2026) plus log storage, on top of the same custom metric charges. Ten million invocations writing 400 bytes each is 4 GB, or $2.00 of ingestion. Set a retention period on those log groups, as the script to set CloudWatch log retention for all log groups does. For new projects, AWS’s documentation now also recommends OpenTelemetry for custom metrics. If a function sends metrics to a third-party service instead, keep that service’s API key out of plain environment variables; the script to find secrets in Lambda environment variables flags keys left there.

How do you set an alarm on a custom metric?

An alarm must name the namespace, metric and every dimension exactly as published. CloudWatch doesn’t aggregate custom metrics across dimensions, so an alarm on Operation=PlaceOrder alone matches nothing if you publish with Service and Stage too.

create-alarm.ts

// create-alarm.ts
// Alarm when the p99 of the custom Latency metric stays above 500 ms for 3 of 3 five-minute periods.
// Usage: AWS_REGION=us-east-1 ALARM_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:oncall npx tsx create-alarm.ts
import { CloudWatchClient, PutMetricAlarmCommand } from "@aws-sdk/client-cloudwatch";

const cloudwatch = new CloudWatchClient({});
const topic = process.env.ALARM_TOPIC_ARN;

await cloudwatch.send(new PutMetricAlarmCommand({
  AlarmName: "checkout-placeorder-p99-latency",
  Namespace: "MyApp/Checkout",
  MetricName: "Latency",
  // Must match the published dimensions exactly, including the defaults added by MetricsBuffer.
  Dimensions: [
    { Name: "Operation", Value: "PlaceOrder" },
    { Name: "Service", Value: "checkout" },
    { Name: "Stage", Value: "prod" },
  ],
  ExtendedStatistic: "p99",
  Period: 300,
  EvaluationPeriods: 3,
  DatapointsToAlarm: 3,
  Threshold: 500,
  ComparisonOperator: "GreaterThanThreshold",
  TreatMissingData: "notBreaching",
  AlarmActions: topic ? [topic] : [],
}));
console.log("alarm created or updated: checkout-placeorder-p99-latency");

TreatMissingData: "notBreaching" matters for metrics that only exist when something happens, such as OrdersFailed. Without it, quiet periods leave the alarm in INSUFFICIENT_DATA, which the script to find CloudWatch alarms stuck in INSUFFICIENT_DATA will flag. To make a failure count reliable, publish count("OrdersFailed", dims, 0) in quiet minutes. The alarm action can be an SNS topic; the guide to publish an SNS message with AWS SDK v3 covers the topic side.

Which IAM permissions does PutMetricData need?

cloudwatch:PutMetricData has no resource type, so the resource is *, but it supports the cloudwatch:namespace condition key. Use it to keep each service in its own namespace.

put-metric-data-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublishCheckoutMetrics",
      "Effect": "Allow",
      "Action": "cloudwatch:PutMetricData",
      "Resource": "*",
      "Condition": {
        "StringEquals": { "cloudwatch:namespace": "MyApp/Checkout" }
      }
    },
    {
      "Sid": "ManageCheckoutAlarms",
      "Effect": "Allow",
      "Action": "cloudwatch:PutMetricAlarm",
      "Resource": "arn:aws:cloudwatch:us-east-1:123456789012:alarm:checkout-*"
    }
  ]
}

The second statement is for the deploy role that creates alarms, not the application. To derive actions from your own code, see how to find the IAM actions your AWS SDK for JavaScript code needs, or paste it into the IAM policy generator for TypeScript code.

Troubleshooting and limits

  • AccessDenied for cloudwatch:PutMetricData. Often the namespace condition: the code publishes to a namespace the policy doesn’t name. The steps to troubleshoot AWS IAM access denied errors help read the message.
  • InvalidParameterValue. NaN or Infinity in a value, a Counts array whose length differs from Values, more than 30 dimensions, or a timestamp older than two weeks.
  • Metric published but not in the console. Wrong Region, or it’s under 15 minutes old. Metrics with no data for two weeks also disappear from the console and ListMetrics, but GetMetricData still returns them.
  • Late data points. Points timestamped 3 to 24 hours ago can take up to 2 hours to be readable, and older ones at least 48 hours. Alarms evaluate current time, so backfilled data won’t trigger them.
  • High resolution costs more. StorageResolution: 1 data is kept at per-second resolution for only 3 hours, and 10- or 30-second alarms cost three times as much. Use it only where a minute is too slow.
  • Throttling. The SDK retries throttled requests; the guide to configure retry and timeout settings in AWS SDK for JavaScript v3 covers the options. Buffering keeps you far below the limits in the first place.

Moving v2 code? The AWS SDK JavaScript v2 to v3 converter drafts the putMetricData change, and the guide to migrate a Node.js app from AWS SDK v2 to v3 covers the rest. For AWS’s own Lambda metrics rather than custom ones, see how to investigate Lambda errors with CloudWatch. To check which custom namespaces exist in an account, ChatWithCloud can answer “Which custom CloudWatch namespaces have metrics in us-east-1?” from a read-only profile; it runs the AWS calls on your machine and sends the results to the AI model to write the answer.

Frequently asked questions

How many metrics can I send in one PutMetricData call?

Up to 1,000 metrics per request, within a 1 MB request size. Each metric can carry up to 150 unique values with a Counts array.

How do I publish a metric with dimensions in AWS SDK v3?

Pass Dimensions: [{ Name: "Operation", Value: "PlaceOrder" }] on each MetricData item. Send the same dimension names on every data point; each distinct combination of names and values is a separate metric.

Is PutMetricData free?

No. You pay per custom metric per month and $0.01 per 1,000 requests in us-east-1, after a free tier of 10 metrics and 1 million API requests.

Can I delete a custom CloudWatch metric?

No. Stop publishing it and it expires 15 months after its last data point. You stop paying for it in the hours you don’t send data.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud