Weekly Performance Budget Email Digests

A dashboard only helps the people who remember to open it. A weekly email digest flips that default: it lands in every stakeholder's inbox on a fixed cadence, summarizes what moved against budget, and names the routes that regressed or recovered. This how-to is part of the Performance Budget Reporting and Scorecards reference, and it walks through the full pipeline: querying your data source once a week, diffing this week against last week, rendering a clean HTML email, and sending it from a scheduled CI job through an email API — with opt-in channels and a verification step so you never spam the org with a broken run.

The digest is deliberately low-fidelity compared with a live board. Its job is to create a weekly heartbeat of accountability, surface the two or three things worth a click, and route people into the deeper executive scorecard or Grafana when they want detail. Keep it short, keep it honest, and make every regression traceable to an owner.

Choose and Query Your Weekly Data Source

You have two credible sources for the numbers in a digest, and the right pick depends on what your budgets are written against. If your budgets gate synthetic lab runs, pull from a self-hosted Lighthouse CI server, which stores every run's category scores and audit values behind a stable REST API. If your budgets are written against field data, pull aggregated percentiles from your custom RUM beacons pipeline instead. Many teams report both: LHCI for the routes under active gating, RUM for the P75 the user actually feels.

Whichever you choose, the digest queries the current week and the prior week so it can diff them. The LHCI server exposes builds and their representative runs; you fetch the latest build per branch and read the median LCP, INP, CLS, and total byte weight per URL. A concrete pull against the LHCI API looks like this.

#!/usr/bin/env bash
set -euo pipefail
LHCI="https://lhci.internal.example.com"
PROJECT="e1b2c3d4-web-storefront"
# Latest build on main this week
BUILD=$(curl -sS "$LHCI/v1/projects/$PROJECT/builds?branch=main&limit=1" \
  | jq -r '.[0].id')
# Representative run URLs + median metrics for that build
curl -sS "$LHCI/v1/projects/$PROJECT/builds/$BUILD/runs?representative=true" \
  | jq '[.[] | {
      url: .url,
      lcp:  (.lhr | fromjson | .audits["largest-contentful-paint"].numericValue),
      inp:  (.lhr | fromjson | .audits["interaction-to-next-paint"].numericValue),
      cls:  (.lhr | fromjson | .audits["cumulative-layout-shift"].numericValue),
      kb:   (.lhr | fromjson | .audits["total-byte-weight"].numericValue / 1024)
    }]' > this_week.json

Run the same query with builds?branch=main&limit=1 scoped to seven days earlier — or simply keep last week's snapshot on disk and reuse it — to produce last_week.json. Store both snapshots as build artifacts so the diff is reproducible and auditable. The whole flow, from the scheduled trigger to the delivered mail, is shown below.

Weekly digest pipeline A scheduled CI job queries LHCI or RUM, diffs against last week, renders HTML, and sends via an email API. Scheduled CI job LHCI server lab runs RUM store field P75 Diff vs last week Render HTML Email API
One scheduled job reads either source, diffs the week, renders, and hands off to the email API.

Diff This Week Against Last Week

The single most useful thing a digest does is classify each route into one of three buckets: new regression (was within budget last week, over budget now), fixed (was over last week, within budget now), and unchanged. That framing keeps the email short and turns a wall of numbers into a to-do list. Every threshold comparison must use the same context your budgets are written against — for the storefront below that means LCP at P75 on a mid-range Android phone throttled to Fast 3G with 4x CPU slowdown, so the digest and the gate never disagree about what "over budget" means.

The diff itself is a small script. It joins the two snapshots on URL, compares each metric against the route's budget, and emits a classified list plus a week-over-week delta. Keeping the budget map in one place means the digest, the gate, and the scorecard all read the same numbers.

import { readFileSync } from "node:fs";

const budgets = {
  "/":          { lcp: 2500, inp: 200, cls: 0.10 }, // P75, mid-range Android, Fast 3G
  "/product":   { lcp: 3000, inp: 200, cls: 0.10 },
  "/checkout":  { lcp: 2800, inp: 200, cls: 0.10 },
};

const now  = index(JSON.parse(readFileSync("this_week.json", "utf8")));
const prev = index(JSON.parse(readFileSync("last_week.json", "utf8")));

function index(rows) {
  return Object.fromEntries(rows.map((r) => [r.url, r]));
}
function over(row, b) {
  return row.lcp > b.lcp || row.inp > b.inp || row.cls > b.cls;
}

const rows = [];
for (const [url, b] of Object.entries(budgets)) {
  if (!now[url] || !prev[url]) continue;
  const wasOver = over(prev[url], b);
  const isOver  = over(now[url], b);
  let status = "unchanged";
  if (!wasOver && isOver) status = "regression";
  if (wasOver && !isOver) status = "fixed";
  rows.push({
    url,
    status,
    lcp: Math.round(now[url].lcp),
    lcpDelta: Math.round(now[url].lcp - prev[url].lcp),
    budget: b.lcp,
  });
}

const regressions = rows.filter((r) => r.status === "regression");
const fixed = rows.filter((r) => r.status === "fixed");
console.log(JSON.stringify({ regressions, fixed, rows }, null, 2));
Weekly diff classification Joining last week and this week by route yields regression, fixed, and unchanged buckets. Last week snapshot This week snapshot Join on URL compare budget New regression within last week, over now Fixed over last week, within now Unchanged no bucket change
Every route lands in exactly one bucket, so the email becomes a short, actionable list.

Render a Clean HTML Email

Email clients are a hostile rendering target: no external stylesheets, no flexbox you can trust, and aggressive clipping in Gmail past roughly 102KB. Author the template with inline styles, a single-column table layout, and a hard budget on total size. The structure that survives everywhere is a header with the headline count, a regression table, a fixed-route callout, and a footer linking to the full board. Below is a compact renderer that turns the diff output into inline-styled HTML.

export function renderDigest({ regressions, fixed, weekOf }) {
  const row = (r) => `
    <tr>
      <td style="padding:8px 12px;border-bottom:1px solid #eee;font:14px sans-serif;">${r.url}</td>
      <td style="padding:8px 12px;border-bottom:1px solid #eee;font:14px sans-serif;color:#8b0a1a;">
        ${r.lcp}ms (+${r.lcpDelta})
      </td>
      <td style="padding:8px 12px;border-bottom:1px solid #eee;font:14px sans-serif;">${r.budget}ms</td>
    </tr>`;
  const table = regressions.length
    ? `<table style="border-collapse:collapse;width:100%;">
         <tr><th align="left" style="padding:8px 12px;font:13px sans-serif;">Route</th>
             <th align="left" style="padding:8px 12px;font:13px sans-serif;">LCP P75</th>
             <th align="left" style="padding:8px 12px;font:13px sans-serif;">Budget</th></tr>
         ${regressions.map(row).join("")}
       </table>`
    : `<p style="font:14px sans-serif;color:#1e874b;">No new regressions this week.</p>`;
  return `<!doctype html>
    <body style="margin:0;background:#f4f4f4;">
      <table role="presentation" width="100%" style="max-width:640px;margin:0 auto;">
        <tr><td style="padding:20px;background:#8b0a1a;color:#fff;font:18px sans-serif;">
          Performance budget digest — week of ${weekOf}
        </td></tr>
        <tr><td style="padding:20px;background:#fff;">
          <h2 style="font:16px sans-serif;">${regressions.length} new regression(s), ${fixed.length} fixed</h2>
          ${table}
        </td></tr>
        <tr><td style="padding:16px 20px;font:12px sans-serif;color:#555;">
          Reply to unsubscribe. Full dashboard link in the footer.
        </td></tr>
      </table>
    </body>`;
}

Keep the payload under 100KB so Gmail does not clip the footer, and always include a plain-text alternative for accessibility and spam scoring. The annotated layout below shows where each block sits.

Digest email anatomy The email stacks a header, headline, regression table, fixed callout, and footer in one column. Header: week of + brand Headline: N regressions, M fixed Regression table route | LCP P75 (+delta) | budget one row per new regression Fixed-route callout Footer: dashboard link + unsubscribe above the fold the action under 100KB
A single-column, inline-styled layout renders identically across Gmail, Outlook, and Apple Mail.

Schedule the Send and Manage Opt-In Channels

Run the digest as a scheduled CI job — a GitHub Actions schedule trigger is enough, and it keeps the send logic beside the same credentials and secrets your gates already use. Fire it Monday morning so the week opens with a shared picture. The workflow queries the source, runs the diff, renders the HTML, and posts to your email API.

name: weekly-budget-digest
on:
  schedule:
    - cron: "0 8 * * 1"   # 08:00 UTC every Monday
  workflow_dispatch: {}     # manual re-run for verification
jobs:
  digest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "20" }
      - name: Query LHCI and diff
        run: node scripts/query.mjs && node scripts/diff.mjs
      - name: Render and send
        env:
          EMAIL_API_KEY: ${{ secrets.EMAIL_API_KEY }}
          DIGEST_LIST: ${{ vars.DIGEST_LIST }}   # comma-separated opt-in addresses
        run: node scripts/send.mjs

The recipient list should be opt-in, not a directory dump. Store it as an editable variable or a small config file in the repo so people add or remove themselves with a pull request, which doubles as an audit trail. Offer at least two channels: an email list for people who want the narrative, and a mirror to a chat webhook for teams that live in Slack. The send script posts the rendered HTML to your email API and returns a message id you can log.

const res = await fetch("https://api.email.example.com/v1/send", {
  method: "POST",
  headers: {
    "authorization": `Bearer ${process.env.EMAIL_API_KEY}`,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    from: "[email protected]",
    to: process.env.DIGEST_LIST.split(","),
    subject: `Performance budget digest — ${regressions.length} new regression(s)`,
    html,
    text: plainText,   // always include a text alternative
  }),
});
if (!res.ok) throw new Error(`Send failed: ${res.status} ${await res.text()}`);
console.log("digest sent", (await res.json()).id);

Verify Before You Trust It

A digest that silently breaks is worse than none, because people stop reading once it cries wolf or goes quiet. Add three cheap checks. First, a dry-run mode that renders to a file and sends only to yourself when triggered via workflow_dispatch, so you can eyeball the layout before Monday. Second, a sanity gate that fails the job if either snapshot is empty or the row count drops more than half week over week — a sign the query broke, not that every route recovered. Third, log the email API message id and assert a 2xx response, so a delivery failure turns the CI job red instead of disappearing. Because the digest reuses the same budget map as your gates, its numbers stay consistent with automated regression detection and the longer-horizon view in tracking budget compliance over time.

Check When it runs Failure signal Action
Dry-run render On workflow_dispatch Layout looks wrong Fix template, re-run
Empty snapshot guard Every scheduled run 0 rows in this_week.json Fail job, alert owner
Row-count drop guard Every scheduled run Rows fall >50% vs last week Fail job, inspect query
Delivery assertion Every scheduled run Non-2xx from email API Fail job, retry send

With those four guards, a broken week turns the pipeline red loudly instead of mailing garbage or nothing at all — and the people on the opt-in list keep trusting that a quiet inbox means a green week.

Frequently Asked Questions

Should the digest pull from LHCI or from RUM data?

Match the source to what your budgets gate. If you gate synthetic lab runs, pull representative medians from the LHCI server. If your budgets are written against field data, pull aggregated P75 from your RUM store. Reporting both is common: LHCI for gated routes, RUM for the percentile users actually feel on a mid-range Android phone.

How do I keep the email from being clipped in Gmail?

Keep the total HTML payload under about 100KB, since Gmail clips messages past roughly 102KB and hides your footer link. Use inline styles and a single-column table layout, avoid embedded images, and always send a plain-text alternative alongside the HTML for accessibility and spam scoring.

What exactly counts as a new regression in the digest?

A route that was within budget in last week's snapshot but is over budget in this week's, compared at the same percentile and device context your gate uses — for example LCP at P75 on a mid-range Android phone throttled to Fast 3G. The diff also tracks the reverse transition as a fixed route.

How should I manage who receives the digest?

Keep the recipient list opt-in and stored as a repo config file or CI variable, so people add or remove themselves through a pull request that leaves an audit trail. Offer a chat-webhook mirror for teams that prefer Slack over email, and never dump an entire directory into the send.

How do I stop a broken run from spamming the team?

Add a dry-run mode triggered manually, an empty-snapshot guard, and a row-count drop guard that fails the job if the data looks wrong instead of mailing it. Assert a 2xx response from the email API and log the message id so delivery failures turn the CI job red rather than passing silently.