Privacy Policy updated — version 1.7, 9 September 2026. A new section describes our Google Sheets add-on: what leaves your spreadsheet (only the factor names and search terms you enter) and what does not. Nothing new is collected, and no new processor is involved. Also recently: directory enquiries go through a form — we pass your message on and keep no copy — and anyone named in a listing can ask us to remove their details. Read the policy.

  1. Home
  2. Guides
  3. Tutorial
  4. Calculate Carbon Emissions in JavaScript
Last reviewed September 2026
Authored by Jeremiah Say

Founder and Lead Systems Architect of GreenCalculus. Translates GHG Protocol methodology into high-precision JavaScript calculation engines. Architect of the MasterBrain data layer covering 16,000+ sourced emission factors, aligned with IPCC AR6 and the GHG Protocol Corporate Standard.

Full profile →

Verified by GreenCalculus Engineering

Automated verification pipeline that audits every page against its underlying calculation code, source documents, and MasterBrain data layer. Traces every figure cell-by-cell to its named source workbook, enforces cell-by-cell provenance attribution on every emission factor, and cross-checks methodology prose against the data layer to catch stated-vs-actual discrepancies before publication.

Governance & verification pipeline →

Calculate Carbon Emissions in JavaScript

Almost every JavaScript carbon integration goes wrong in the same place, and it is not the arithmetic. It is that the decision you are actually making is where the API key lives, and that decision is forced by the runtime rather than chosen by you. A browser cannot hold a secret. A Node process can. An edge worker can, but pays for every call it does not cache. Those three constraints produce three different architectures from the same SDK, and picking the wrong one is how you end up either shipping a credential to every visitor or proxying calls you never needed to proxy. This page walks all three, with code that has been run.

Quick Answer

Install greencalculus from npm. In the browser, use only the keyless surface: browse, search and grid intensity all work with no credential, they are CORS-open and edge-cached, and they are enough to display a factor with its source. Never put a key in front-end code, and note that the API refuses keyless calculation by design rather than quietly returning a number. In Node, construct the client with a key and you get calculation, past-version pinning, and a response carrying provenance, proof URLs and a hash receipt. At the edge, cache on the data version, which the API returns in a response header so you never have to parse the body to find it.

The question that decides the architecture

Before any code, answer one thing: can this runtime keep a secret from the person using it? Everything else follows.

The same SDK, three runtimes, three different shapes
Runtime Can hold a key What it can do The failure to design against
Browser No. Anything shipped to the client is public. Browse, search, grid intensity, display a factor with its citation. Bundling a key. It will be read.
Node server Yes, from the environment. Everything: calculation, pinning, receipts, provenance. Refetching a factor per request instead of caching it.
Edge worker Yes, as a binding or secret. Everything, close to the user. Caching without the data version in the key, so a data release never invalidates.

The useful surprise is how much of the API sits in the first row. The keyless surface is not a crippled demo tier — it carries the full factor row including the value, the source identifier and the spreadsheet cell it came from. What it will not do is calculate for you.

The browser: everything except the calculation

Install the package. It has zero dependencies, ships its own TypeScript types, and needs Node 18 or newer where it runs server-side.

npm install greencalculus

Construct a client with no key at all. This is a supported mode, not a degraded one.

import GreenCalculus from "greencalculus";

const gc = new GreenCalculus({});           // no key, no account, no signup

const page = await gc.browse({ key_prefix: "fuels.gbr.", limit: 5 });
console.log(page.meta.gc_version);          // "2026.189" — the data version served
console.log(page.factors[0].key);           // "fuels.gbr.aviation_spirit.kwh_gcv"
console.log(page.factors[0].factor.value);  // 0.24382
console.log(page.factors[0].source.cell_ref); // "'Fuels'!D62"

Three things about that response matter more than the number. It carries meta.gc_version, so you always know which edition of the data you rendered. It carries source.cell_ref, which is the actual cell in the publisher’s spreadsheet — the difference between a figure you can defend and one you cannot. And it carries a ready-made citation string per row, so you are not assembling attribution by hand. The reasoning behind that shape is in anatomy of a traceable carbon number.

Free-text search is the same surface, also keyless, when you do not know the key:

const hits = await gc.search("diesel", 3);
console.log(hits.meta.total);               // 590 matching rows in the corpus
console.log(hits.factors[0].key);
Why this works from a browser at all

The browse route sends access-control-allow-origin: *, so a page can call it directly with no proxy of your own. It is also genuinely cached at the edge rather than nominally so, though the first request will mislead you: a cold miss comes back no-store with cf-cache-status: DYNAMIC, and every request after it returns cache-control: public, max-age=14400 with cf-cache-status: HIT. If you test once and conclude nothing is cached, test twice. That combination is what makes a keyless front-end integration practical instead of merely permitted — you are not paying a round trip to origin for a factor that changes a few times a year. How often that actually is, measured, is in how often do emission factors change.

Now the boundary. Ask a keyless client to calculate and it refuses, in the SDK, before a request leaves the machine:

try {
  await gc.electricity({
    consumption: { value: 1000, unit: "kWh" },
    location_factor_key: "grid.gbr.electricity.location_based",
  });
} catch (e) {
  console.log(e.status);   // 401
  console.log(e.message);
  // "Calculations requires an API key. Free, no card: …"
}

That refusal is the design working. A carbon API that quietly returned a plausible number to an unauthenticated caller would be worse than one that stopped, because the number would be indistinguishable from a sourced one downstream. The same principle governs a factor that does not exist: you get a refusal that names what was not understood, never a zero.

Node: what the key actually buys

A free key changes the client construction and nothing else about how you write code.

const gc = new GreenCalculus({ apiKey: process.env.GREENCALCULUS_API_KEY });

What changes is the response. The keyless path resolves a single factor through the open browse route and mirrors the lookup envelope so the same accessors work. The keyed path hits the dedicated lookup endpoint and returns five additional fields. Measured on grid.gbr.electricity.location_based, both return the identical value of 0.13096 kg CO2e per kWh — the key does not buy a better number, it buys the evidence around it:

Fields present only on a keyed lookup
Field What it carries
provenance Source id, full publication name, the publishing body, and the publisher’s own URL.
verification Whether the gas components sum to the headline value, the GWP set applied, and any uncertainty the publisher states.
proof_urls A permanent link to this exact factor at this exact data version.
attribution The licence text you are required to reproduce.
served_from Whether the row came from the edge or the origin.

For the UK grid row above, provenance names the UK Government GHG Conversion Factors 2026, published by the Department for Energy Security and Net Zero, and verification.components_sum_to_value comes back true — the carbon dioxide, methane and nitrous oxide components genuinely add up to the headline figure. That is a check you would otherwise be doing by hand, if at all.

A footprint endpoint you can ship

Here is the whole pattern in one Express handler: take a request, calculate, and return the number with the evidence still attached. The attachment is the point. A route that returns a bare float has thrown away everything that makes the float defensible.

import express from "express";
import GreenCalculus, { GreenCalculusError } from "greencalculus";

const gc = new GreenCalculus({ apiKey: process.env.GREENCALCULUS_API_KEY });
const app = express();
app.use(express.json());

app.post("/footprint/electricity", async (req, res) => {
  const { kwh, country = "GBR" } = req.body;

  if (typeof kwh !== "number" || !Number.isFinite(kwh) || kwh < 0) {
    return res.status(400).json({ error: "kwh must be a non-negative number" });
  }

  try {
    const r = await gc.electricity({
      consumption: { value: kwh, unit: "kWh" },
      location_factor_key: `grid.${country.toLowerCase()}.electricity.location_based`,
    });

    res.json({
      kg_co2e:   r.location_based.emissions.value,
      formula:   r.location_based.formula,
      factor:    r.location_based.factor.value,
      source:    r.location_based.factor.citation.text,
      proof:     r.proof_urls[0],
      version:   r.meta.gc_version,
      receipt:   r.receipt.id,
    });
  } catch (e) {
    if (e instanceof GreenCalculusError) {
      // The API names what it did not understand. Pass that through rather
      // than flattening every failure into a 500 the caller cannot act on.
      return res.status(e.status).json({ error: e.code, message: e.message });
    }
    throw e;
  }
});

Called with 1,000 kWh, that endpoint returns 130.96 kg CO2e, and alongside it the string "1000 kWh × 0.13096 (kg CO2e per kWh) = 130.96 kg CO2e". The API composes the working for you, which sounds cosmetic until someone queries a total and you can show the multiplication rather than reconstructing it.

Two fields in that response are worth dwelling on.

proof is a permanent URL of the form verify.greencalculus.com/<key>@<version>. It resolves to that factor as it stood at that data version, forever, and it is safe to store in your own records or print on a report.

receipt is a SHA-256 hash of the response itself. The response tells you how to check it: recompute the hash over the payload with the receipt and attribution keys removed and all object keys sorted recursively, and it must match. That gives you a tamper-evident record of a calculation without trusting your own database. If you are storing carbon numbers that will later be assured, store the receipt beside them — the practice is covered in emission factor version control.

Read the error, do not guess the body shape

The electricity endpoint takes consumption, not activity, and it takes an object rather than a bare number. Send the wrong shape and you get 400 gc_elec_bad_consumption with the message “Provide consumption: { value: number, unit: kWh | MWh | GWh }” — the correct shape is in the error. This matters more than it sounds: the published specification named that field activity for a period, and every client that followed the spec instead of the error failed its first call. Where the two disagree, the error is the one that was generated by the code actually running.

Reproducibility, and the check nobody writes

Pass a data version as the second argument to factor() and you get that factor as it stood then, rather than as it stands now:

const now = await gc.factor("grid.gbr.electricity.location_based");
console.log(now.served_version);            // "2026.189"

const then = await gc.factor("grid.gbr.electricity.location_based", "2026.150");
console.log(then.served_version);           // "2026.150"
console.log(then.served_from);              // "edge"

This is the machinery behind rebuilding a figure you published months ago. Pinning needs a key, and a keyless client refuses it rather than handing back a current value under a past label:

await new GreenCalculus({}).factor("grid.gbr.electricity.location_based", "2026.150");
// GreenCalculusError: [401] Pinning a past data version (asOf) requires an API key.

Now the part that is easy to miss, and the reason this section exists. Asking for a version is not the same as being served it. There are two ways a pin can go unhonoured, and only one of them raises. If the factor has no row at that version — it was added later, or withdrawn earlier — you get a 404 that names the key and the version, and says whether the key is in the current corpus so you can tell a genuine absence from a typo. But if the version itself is not in the archive, you get a 200 carrying the current row, with version_pin.matched set to false. That second case is the dangerous one, because a try/catch will not see it. So do not infer the pin from the status code. The response tells you what you were actually served, and it is on you to look:

const REQUESTED = "2026.150";
const row = await gc.factor(key, REQUESTED);

if (row.served_version !== REQUESTED) {
  // You asked for one version and were served another. Anything you do with
  // this number is happening on a different data edition than you intended.
  throw new Error(
    `pin not honoured: asked ${REQUESTED}, served ${row.served_version}`
  );
}

Three lines, and they are the difference between reproducibility you have verified and reproducibility you have assumed. Write them once in whatever wraps your client and never think about it again. The general habit — trust the label the response carries, not the parameter you sent — applies well beyond this API.

At the edge: cache on the data version

Workers and edge functions are an excellent fit here, because factor data is read constantly and changes rarely. The trap is caching on the factor key alone. Do that and a data release never invalidates anything: your worker keeps serving a superseded value until the entry happens to expire.

The API makes this easy to get right. Every browse response carries the data version in a header, so you can build a cache key from it without parsing the body:

Response headers on the keyless browse route
Header Example Use
x-gc-version 2026.189 Put it in your cache key. A data release changes it and everything rolls over.
x-gc-updated 2026-09-10 Show “data current as of” without a second call.
cache-control public, max-age=14400 Four hours. Respect it rather than inventing your own.
x-gc-cache HIT Confirm you are being served from the edge and not paying for origin.
export default {
  async fetch(request, env, ctx) {
    const upstream =
      "https://api.greencalculus.com/v1/factors?key_prefix=grid.&limit=100";

    // Read the data version off the RESPONSE. There is no HEAD route to probe
    // it with — HEAD returns 404 — and you would not want the extra round trip
    // anyway. This fetch is cheap because the upstream is itself edge-cached
    // for four hours, so it usually never leaves the colo.
    const upstreamRes = await fetch(upstream, {
      cf: { cacheEverything: true, cacheTtl: 14400 },
    });
    const version = upstreamRes.headers.get("x-gc-version") ?? "unknown";

    // The version is IN the cache key, so a data release misses cleanly.
    const cache = caches.default;
    const cacheKey = new Request(`https://cache.internal/grid?v=${version}`);

    const cached = await cache.match(cacheKey);
    if (cached) return cached;

    const rows = await upstreamRes.json();
    const out = new Response(JSON.stringify(shapeForYourApp(rows)), {
      headers: {
        "content-type":   "application/json",
        "cache-control":  "public, max-age=14400",
        "x-data-version": version,
      },
    });

    ctx.waitUntil(cache.put(cacheKey, out.clone()));
    return out;
  },
};

Because the version is part of the key, a data release produces a clean miss and a refill rather than a stale hit. And since this route is keyless, an edge worker fronting it needs no secret at all — which means you can put it in front of a static site without an environment binding.

Website carbon, and the basis trap

If you are estimating the carbon of a web page rather than of an organisation, you are probably using CO2.js from the Green Web Foundation. It wants a grid intensity in grams per kilowatt hour, and the SDK ships helpers to supply one. They work without a key:

import GreenCalculus, { gridIntensity, toCo2jsOptions } from "greencalculus";

const gc = new GreenCalculus({});                  // keyless is fine here
const gi = await gridIntensity(gc, "GBR");

console.log(gi.gco2PerKwh);                        // 217.41
console.log(gi.basis);                             // "lifecycle"
console.log(toCo2jsOptions(gi));
// { gridIntensity: { device: 217.41, dataCenter: 217.41, network: 217.41 } }

Note the argument order: the client comes first, then the ISO 3166-1 alpha-3 country code. Passing the country alone is the mistake everyone makes once, and it throws a message telling you so.

Lifecycle is not the Scope 2 number, and the gap is about 40 per cent

For Great Britain the helper returns 217.41 gCO2e per kWh, because it defaults to lifecycle basis — fuel supply chain and plant manufacture included, which is what a website carbon estimate wants. The location-based factor you would use for Scope 2 reporting is 130.96. They are both correct and they answer different questions, so reaching for the number you happen to have already will understate a website estimate by roughly two fifths. Ask for the other basis explicitly when you need it, with gridIntensity(gc, "GBR", { basis: "location_based" }). Why two defensible sources disagree this much is the subject of why emission factors disagree.

Use toCo2jsOptions() rather than assembling the object yourself. CO2.js expects bare numbers for device, dataCenter and network; hand it a wrapped object such as { value: 217.41 } and it is silently ignored, your estimate quietly falls back to the library default, and nothing anywhere reports a problem. If you do build the options by hand, assert that the estimate actually changes when you pass them.

Every result also carries a finished citation string naming Ember as the source, the exact CSV row, and the retrieval date, which is what you put in the footnote. How to cite an emission factor covers what a complete citation needs.

Three packaging facts that cost an hour

All three are things you would otherwise discover through a confusing error message.

The package is ESM only. It declares "type": "module" and its export map offers only an import condition. Calling require("greencalculus") fails with ERR_PACKAGE_PATH_NOT_EXPORTED, which reads like a missing file rather than a module-format mismatch. In a CommonJS project, use a dynamic import: const { default: GreenCalculus } = await import("greencalculus").

There is no subpath export. The CO2.js helpers live in a file called co2js, so importing from "greencalculus/co2js" is the natural guess and it fails with the same error. Every helper is re-exported from the package root — import gridIntensity, toCo2jsOptions, co2jsOptionsFor, toGramsPerKwh and citationFor from "greencalculus" directly.

Unit conversion is a helper, not a guess. The corpus publishes in the publisher’s own units, which for grid electricity means kilograms per kWh while CO2.js wants grams. toGramsPerKwh(0.13096, "kg CO2e per kWh") returns 130.96 and understands tonnes per MWh and the other common spellings. Multiplying by 1,000 yourself works right up until you meet a row published in a different unit.

Everything above also exists in Python with the same response shapes and the same keyless boundary — see calculate carbon emissions in Python. If you are still choosing a provider rather than integrating one, how to evaluate a carbon data API is the better starting point.

Calculate carbon emissions in JavaScript — browser, Node and edge worker
Save to Pinterest Download · 1000×1500 JPG

Frequently asked questions

For reading factors, yes. The browse and search routes need no key, send access-control-allow-origin: *, and are edge-cached for four hours, so a front-end can fetch and display factors with their sources directly. Calculation is the exception and needs a key, which means it needs a server or a worker. In practice that split is convenient rather than annoying, because the read surface is what most interfaces need and the calculation is what you want audited in one place anyway.

Only in code you are certain runs on the server: a route handler, a server action, or server-side data loading. The danger with these frameworks is that the boundary is not visually obvious — a component can be moved from server to client in one line and take its imports with it. Never give the variable a client-exposed prefix such as NEXT_PUBLIC_, and construct the client in a module that cannot be imported from the client bundle. If you want a rule that survives refactoring: keep the keyed client in a file whose name makes its server-only status impossible to miss.

Yes. The package ships its own declarations, so there is no separate types package to install and nothing to configure. The client, the error class and the CO2.js helper results are all typed, which is how the pinning mistake described above tends to get caught early — a response served at a different version than you requested is still a valid object, so the type system will not save you there, but the field is right in front of you.

You get a refusal that names the problem, not a zero and not an empty success. Most misses are a naming difference rather than genuine absence, so the fastest recovery is a keyless search() call for the words you were using, which will usually surface the canonical key. Returning zero for an unknown factor would be the worst available behaviour, because zero is a legitimate emission value and the error would travel silently into a total.

Pass the data version you used as the second argument to factor(), then check served_version on the response actually equals what you asked for. Better still, store the proof URL and the receipt hash at the time you publish: the proof URL resolves to that factor at that version permanently, and the receipt lets you demonstrate the calculation was not altered afterwards. Reproducing from a stored receipt is stronger than reproducing from a re-fetch, because it does not depend on the archive still holding what you need.

Because they are different measurements. The helper defaults to lifecycle intensity, which includes the fuel supply chain and plant construction, and that is the right basis for estimating the carbon of digital services. Scope 2 location-based reporting uses generation-only intensity. For Great Britain the two are 217.41 and 130.96 grams per kWh respectively. Neither is more accurate; using one where the other belongs is a category error rather than a rounding difference, and it is large enough to change conclusions.

It depends on the underlying publisher’s licence, and the response tells you which one applies rather than leaving you to work it out. Keyed responses carry an attribution field and every row carries a finished citation string. The UK government factors are Open Government Licence v3.0 and the Ember grid data is CC BY 4.0, both of which require attribution; some sources in the corpus are more restrictive and cannot be republished at all. Emission factor licences sets out what each one permits.

Scroll to Top