How to Build Your Own Analytics API in Node.js

Build your own analytics API in Node.js: an Express collector with validation, CORS allow-list and rate limiting, plus stats endpoints for traffic and funnels.

Your tracking script now fires events, and they need somewhere to go. That somewhere is an analytics API in Node.js: one public endpoint that accepts events from strangers’ browsers, and a few private endpoints that return numbers to your dashboard. The public one is the most exposed code you will ever write, because anyone on the internet can send it any bytes they like.

This article builds both halves with Express and plain pg. The collector validates every event, enforces a CORS allow-list, limits request rates, caps payload size and writes with parameterized SQL. The stats API returns the JSON that the HTML dashboard already expects.

The events come from the client code in tracking button clicks and form submissions with JavaScript, and the queries behind the stats follow SQL queries for user analytics, translated to PostgreSQL. I will not design the schema here, since that is PostgreSQL schema design for analytics. I will also not build batching or queues, which belong to async event tracking.

Executive Summary: An analytics collector should accept valid events quickly and reject everything else. This post builds one in about 280 lines of Express 5 and pg, with rate limiting, a size-limited parser, validation, and parameterized inserts, uses event_id with ON CONFLICT DO NOTHING to stop double counts, and protects stats endpoints with a token, since CORS does not stop scripts.

Requirements and request flow

Write the requirements down before the first line of code. They decide every later trade-off.

  • POST /v1/events accepts one event or a batch array of up to 50 events.
  • The collector answers in milliseconds and never trusts the body.
  • Only the Acme Shop origins may call it from a browser.
  • No raw IP address and no other personal data reaches the database.
  • GET /v1/stats/pageviews, /v1/stats/funnel and /v1/stats/retention return JSON for a date range, behind a token.

Here is the request path through the middleware chain. Each box can reject the request, and the cheapest checks run first.

browser / sendBeacon / curl
        |
        v
  [ CORS allow-list ]  ---- wrong origin: no CORS headers, browser blocks
        |
        v
  [ rate limiter ]     ---- too many requests: 429
        |
        v
  [ JSON parser, 100 KB limit ]  ---- too big: 413, bad JSON: 400
        |
        v
  [ validator ]        ---- bad events: reported in "rejected"
        |
        v
  [ parameterized INSERT ... ON CONFLICT DO NOTHING ]
        |
        v
  202 { accepted, duplicates, rejected }

Order matters. The rate limiter sits before the parser so a flood of large bodies is dropped before you spend CPU parsing them. Validation sits after parsing because it needs the data. This ordering is the cheapest defense you have.

Project Setup for the Analytics API in Node.js

Use a current Node.js LTS release. Express 5 requires Node.js 18 or newer, as the Express 5 migration guide states, and the series targets the current LTS line. Install four packages.

mkdir acme-analytics-api && cd acme-analytics-api
npm init -y
npm pkg set type=module
npm install express cors express-rate-limit pg

Express 5 changes two things you will meet here. Rejected promises in async handlers reach the error handler automatically, so you need no try/catch wrapper in every route. And req.body is undefined, not {}, when no parser handled the request. The migration guide documents both. The code below checks for the second case explicitly.

A starter events table

The API needs a table to write to. This is a starter definition that matches the canonical event shape. It adds one column, received_at, which the server fills. The schema article revisits the types, naming and derived tables properly, so treat this as scaffolding.

CREATE TABLE events (
  event_id     uuid        PRIMARY KEY,
  event_name   text        NOT NULL,
  occurred_at  timestamptz NOT NULL,
  received_at  timestamptz NOT NULL DEFAULT now(),
  anonymous_id text        NOT NULL,
  user_id      text,
  session_id   text        NOT NULL,
  page_url     text        NOT NULL,
  referrer     text,
  user_agent   text,
  properties   jsonb       NOT NULL DEFAULT '{}'
);

CREATE INDEX events_occurred_at_idx ON events (occurred_at);

The primary key on event_id is the idempotency guard. The client generates the UUID, so a retried request carries the same key and the database refuses the copy. Without it, every network retry risks a double count.

Validate every field, because every field is hostile

The wrong approach is to pass the body straight to the database. It looks efficient, and it hands control of your table to anyone with curl.

// WRONG: trusts the client completely
app.post('/v1/events', express.json(), async (req, res) => {
  const e = req.body;
  await pool.query(
    `INSERT INTO events (event_id, event_name, occurred_at, anonymous_id, session_id, page_url)
     VALUES ('${e.event_id}', '${e.event_name}', '${e.occurred_at}', '${e.anonymous_id}', '${e.session_id}', '${e.page_url}')`
  );
  res.sendStatus(200);
});

This code has three faults. String concatenation lets a crafted page_url inject SQL. No size cap lets a 50 MB body exhaust memory. No name check lets someone invent a million distinct event_name values and ruin every report that groups by it. A validator fixes the last two, and parameters fix the first.

const EVENT_NAMES = new Set([
  'page_view', 'button_click', 'form_submit',
  'signup_completed', 'add_to_cart', 'purchase_completed',
]);
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const MAX_PROPS_BYTES = 4096;
const MAX_AGE_MS = 7 * 86_400_000;
const MAX_FUTURE_MS = 5 * 60_000;
const ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;

const needStr = (v, max) => typeof v === 'string' && v.length > 0 && v.length <= max;
const optStr = (v, max) => v == null || (typeof v === 'string' && v.length <= max);

// PostgreSQL text and jsonb reject the NUL character, so one hostile event would fail the whole insert.
function containsNul(value) {
  let found = false;
  JSON.stringify(value, (key, v) => {
    if (key.includes('\0') || (typeof v === 'string' && v.includes('\0'))) found = true;
    return v;
  });
  return found;
}

function validateEvent(e, uaHeader) {
  if (e === null || typeof e !== 'object' || Array.isArray(e)) {
    return { errors: ['event must be an object'] };
  }
  if (containsNul(e)) return { errors: ['strings must not contain NUL characters'] };
  const errors = [];
  if (typeof e.event_id !== 'string' || !UUID_V4.test(e.event_id)) errors.push('event_id must be a UUID v4');
  if (!EVENT_NAMES.has(e.event_name)) errors.push('event_name is not in the tracking plan');

  const ts = typeof e.occurred_at === 'string' && ISO_8601.test(e.occurred_at) ? Date.parse(e.occurred_at) : NaN;
  if (Number.isNaN(ts)) errors.push('occurred_at must be an ISO 8601 timestamp');
  else if (ts > Date.now() + MAX_FUTURE_MS || ts < Date.now() - MAX_AGE_MS) errors.push('occurred_at is outside the accepted window');

  if (!needStr(e.anonymous_id, 64)) errors.push('anonymous_id is required (max 64 chars)');
  if (!optStr(e.user_id, 64)) errors.push('user_id must be a string (max 64 chars) or null');
  if (!needStr(e.session_id, 64)) errors.push('session_id is required (max 64 chars)');
  if (!optStr(e.referrer, 2048)) errors.push('referrer is too long');

  let pageUrl = null;
  if (needStr(e.page_url, 2048)) {
    try {
      pageUrl = new URL(e.page_url);
    } catch { /* handled below */ }
  }
  if (!pageUrl || !['http:', 'https:'].includes(pageUrl.protocol)) errors.push('page_url must be an http(s) URL');

  const props = e.properties ?? {};
  if (typeof props !== 'object' || Array.isArray(props)) errors.push('properties must be an object');
  else if (Buffer.byteLength(JSON.stringify(props)) > MAX_PROPS_BYTES) errors.push('properties exceeds 4096 bytes');

  if (errors.length) return { errors };
  return {
    errors,
    value: {
      event_id: e.event_id.toLowerCase(),
      event_name: e.event_name,
      occurred_at: new Date(ts).toISOString(),
      anonymous_id: e.anonymous_id,
      user_id: e.user_id || null,
      session_id: e.session_id,
      page_url: e.page_url,
      referrer: e.referrer || null,
      user_agent: String(e.user_agent ?? uaHeader ?? '').slice(0, 512) || null,
      properties: props,
    },
  };
}

Read the delta. The event name comes from an allow-list that mirrors the tracking plan, so the table can only ever contain six names. Timestamps must fall between seven days ago and five minutes ahead, which tolerates offline devices and clock skew but blocks nonsense dates that would wreck daily reports. Every string has a length limit, and the validator builds a fresh object, so unknown keys never reach the database. The strict ISO 8601 pattern matters because Date.parse happily accepts strings like "1" or a date with no zone, which it reads in the server’s local time.

One more check looks odd until it bites you. PostgreSQL text and jsonb columns reject the NUL character (\0), and valid JSON can carry it. Without the containsNul guard, a single hostile event makes the insert throw, and the whole batch returns a 500.

A mistake I have seen in production is accepting any event_name the client sends. A typo in one release shipped pageview instead of page_view, and for days the funnel showed zero traffic while the table filled with rows nobody queried. A strict allow-list turns that silent bug into a loud rejected entry on day one.

The trade-off is flexibility. Adding a new event now needs a server change. That friction is useful, because it forces the tracking plan to stay current.

The collector endpoint and parameterized inserts

Now wire the pieces together. The insert builds placeholders like $1, $2 from constant column names, and passes every value in a separate array. The SQL text never contains client data, which is what parameterization means.

import express from 'express';
import cors from 'cors';
import { rateLimit } from 'express-rate-limit';
import pg from 'pg';

const PORT = Number(process.env.PORT ?? 4000);
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? 'https://acme-shop.example,http://localhost:8080').split(',');
const MAX_BATCH = 50;

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
const app = express();
app.disable('x-powered-by');
// Behind exactly one reverse proxy, also add: app.set('trust proxy', 1);

app.use(cors({
  origin: ALLOWED_ORIGINS,
  methods: ['GET', 'POST'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 600,
}));

const COLUMNS = [
  'event_id', 'event_name', 'occurred_at', 'anonymous_id', 'user_id',
  'session_id', 'page_url', 'referrer', 'user_agent', 'properties',
];

async function insertEvents(rows) {
  const values = [];
  const tuples = rows.map((r, i) => {
    values.push(
      r.event_id, r.event_name, r.occurred_at, r.anonymous_id, r.user_id,
      r.session_id, r.page_url, r.referrer, r.user_agent, JSON.stringify(r.properties),
    );
    const placeholders = COLUMNS.map((_, j) => `$${i * COLUMNS.length + j + 1}`);
    return `(${placeholders.join(', ')})`;
  });
  const sql = `INSERT INTO events (${COLUMNS.join(', ')})
               VALUES ${tuples.join(', ')}
               ON CONFLICT (event_id) DO NOTHING`;
  const result = await pool.query(sql, values);
  return result.rowCount;
}

const ingestLimiter = rateLimit({
  windowMs: 60_000,
  limit: 120,
  standardHeaders: 'draft-7',
  legacyHeaders: false,
});

// text/plain is accepted because navigator.sendBeacon with a string body sends it
const eventParser = express.json({ limit: '100kb', type: ['application/json', 'text/plain'] });

app.post('/v1/events', ingestLimiter, eventParser, async (req, res) => {
  if (req.body === undefined) return res.status(400).json({ error: 'JSON body required' });
  const batch = Array.isArray(req.body) ? req.body : [req.body];
  if (batch.length === 0 || batch.length > MAX_BATCH) {
    return res.status(400).json({ error: `send 1 to ${MAX_BATCH} events per request` });
  }

  const rows = [];
  const rejected = [];
  batch.forEach((raw, index) => {
    const { errors, value } = validateEvent(raw, req.get('user-agent'));
    if (errors.length) rejected.push({ index, errors });
    else rows.push(value);
  });

  if (rows.length === 0) return res.status(400).json({ error: 'validation_failed', rejected });
  const inserted = await insertEvents(rows);
  res.status(202).json({ accepted: rows.length, duplicates: rows.length - inserted, rejected });
});

Several choices here deserve a reason. The handler stores the valid events and reports the rest, instead of rejecting a whole batch for one bad event. A client that retries a rejected batch forever would otherwise loop on one malformed entry. A fully invalid request returns 400, which tells well-behaved clients to stop retrying.

The insert is synchronous, so the request waits for PostgreSQL. That is simple and correct, and it caps throughput at what your pool can write. The 202 status is deliberate: clients should treat any 2xx as success, so you can move to a truly deferred write later, as covered in the async tracking article, without changing the client.

The node-postgres pooling guide recommends pool.query for single statements, because it checks out and releases a client for you. Do that here, and avoid manual connect() calls you might forget to release.

CORS: an allow-list that does what you think

CORS is the most misunderstood part of a collector. It is a browser rule, not a firewall. When a page on https://acme-shop.example sends a request to https://analytics.acme-shop.example, the browser checks the response headers and refuses to show the response unless the origin is allowed. The MDN CORS guide explains the full mechanism.

The cors middleware accepts an array of exact origins, as its documentation shows, and sends the matching origin back with Vary: Origin. An origin that is not in the list receives no CORS headers, and the browser blocks the call. Two facts matter for analytics.

  • A request still reaches your server. CORS limits what the browser lets a page read. For a simple POST, the request is sent anyway. Validation and rate limits must therefore stand on their own.
  • Beacons skip the preflight if they use text/plain. A POST with a JSON content type triggers a preflight OPTIONS request first. A string body sent through sendBeacon goes out as text/plain, which counts as a simple request. This is why the parser above accepts both types.

Never use origin: '*' on the stats endpoints, and prefer an exact list on the collector. The cost is operational: each new site or staging host needs a config change. That friction is cheap compared with a scraper filling your table from a thousand origins.

Rate limiting without storing IP addresses

A rate limiter must identify callers, and the usual identifier is the IP address. That sits awkwardly with the rule that the series never stores raw IPs. The resolution is that express-rate-limit keeps counters in process memory for the length of the window, and the collector never writes the address to the database. Transient use for abuse control is different from storing it as analytics data. The cookieless identification article later in the series returns to what hashing and retention mean for privacy.

Check the proxy setup carefully. Behind a reverse proxy, Express sees the proxy’s address for every request unless you set trust proxy to match your real hop count. Get it wrong in one direction and every user shares one bucket, so the 121st visitor of the minute gets blocked. Get it wrong in the other direction and a client can forge X-Forwarded-For and dodge the limit entirely.

Shared addresses cut the other way. An office or school behind one NAT address shares a single bucket, so a generous limit protects you from bots without punishing real visitors. A single process also keeps its counters privately. If you run four instances, each allows 120 requests per minute, so the effective limit is 480. Use a shared store such as Redis once you scale out, or put the limit at the proxy or CDN. For one instance, memory is fine.

The stats API: token, ranges and three queries

The reporting endpoints serve business data, so they need real access control. This example uses one shared bearer token, compared in constant time. It is simple, and it suits an internal dashboard. Swap in your own session or SSO check when more people need access.

import crypto from 'node:crypto';

const STATS_TOKEN = process.env.STATS_TOKEN;
if (!STATS_TOKEN) throw new Error('STATS_TOKEN is required');
const tokenDigest = crypto.createHash('sha256').update(STATS_TOKEN).digest();

function requireStatsToken(req, res, next) {
  const header = req.get('authorization') ?? '';
  const supplied = header.startsWith('Bearer ') ? header.slice(7) : '';
  const digest = crypto.createHash('sha256').update(supplied).digest();
  if (!crypto.timingSafeEqual(digest, tokenDigest)) {
    return res.status(401).json({ error: 'unauthorized' });
  }
  next();
}

const DAY = /^\d{4}-\d{2}-\d{2}$/;
const validDay = (s) => typeof s === 'string' && DAY.test(s)
  && new Date(`${s}T00:00:00Z`).toISOString().slice(0, 10) === s;

function parseRange(req, res, next) {
  const { from, to } = req.query;
  if (!validDay(from) || !validDay(to) || from > to
      || Date.parse(to) - Date.parse(from) > 365 * 86_400_000) {
    return res.status(400).json({ error: 'from and to must be YYYY-MM-DD, from <= to, at most 365 days apart' });
  }
  res.locals.range = { from, to };
  next();
}

// Inclusive UTC days: [from 00:00, to + 1 day 00:00)
const RANGE = `occurred_at >= $1::date::timestamp AT TIME ZONE 'UTC'
               AND occurred_at < ($2::date + 1)::timestamp AT TIME ZONE 'UTC'`;

Hashing both values before timingSafeEqual solves a practical problem: that function throws when its inputs differ in length, and hashing gives both a fixed 32 bytes. The range check round-trips each date through Date, so impossible dates such as 2025-02-31 fail with a 400 instead of a database error. The range expression is a constant string, and the dates travel as parameters.

Now the three endpoints. The first returns exactly the contract from the dashboard article, including zero-filled days and a separate distinct count for the range totals.

app.get('/v1/stats/pageviews', requireStatsToken, parseRange, async (req, res) => {
  const { from, to } = res.locals.range;
  const [totals, daily] = await Promise.all([
    pool.query(
      `SELECT count(*)::int AS pageviews,
              count(DISTINCT anonymous_id)::int AS visitors,
              count(DISTINCT session_id)::int AS sessions
       FROM events
       WHERE event_name = 'page_view' AND ${RANGE}`,
      [from, to],
    ),
    pool.query(
      `WITH days AS (
         SELECT d::date AS day
         FROM generate_series($1::date::timestamp, $2::date::timestamp, interval '1 day') AS d
       ), agg AS (
         SELECT (occurred_at AT TIME ZONE 'UTC')::date AS day,
                count(*)::int AS pageviews,
                count(DISTINCT anonymous_id)::int AS visitors
         FROM events
         WHERE event_name = 'page_view' AND ${RANGE}
         GROUP BY 1
       )
       SELECT to_char(days.day, 'YYYY-MM-DD') AS date,
              COALESCE(agg.pageviews, 0) AS pageviews,
              COALESCE(agg.visitors, 0) AS visitors
       FROM days LEFT JOIN agg USING (day)
       ORDER BY days.day`,
      [from, to],
    ),
  ]);
  res.json({ from, to, totals: totals.rows[0], daily: daily.rows });
});

app.get('/v1/stats/funnel', requireStatsToken, parseRange, async (req, res) => {
  const { from, to } = res.locals.range;
  const { rows } = await pool.query(
    `SELECT count(*) FILTER (WHERE t_view IS NOT NULL)::int AS viewed,
            count(*) FILTER (WHERE t_cart >= t_view)::int AS carted,
            count(*) FILTER (WHERE t_buy >= t_cart AND t_cart >= t_view)::int AS purchased
     FROM (
       SELECT anonymous_id,
              min(occurred_at) FILTER (WHERE event_name = 'page_view') AS t_view,
              min(occurred_at) FILTER (WHERE event_name = 'add_to_cart') AS t_cart,
              min(occurred_at) FILTER (WHERE event_name = 'purchase_completed') AS t_buy
       FROM events
       WHERE ${RANGE}
       GROUP BY anonymous_id
     ) s`,
    [from, to],
  );
  const r = rows[0];
  res.json({ from, to, steps: [
    { event_name: 'page_view', visitors: r.viewed },
    { event_name: 'add_to_cart', visitors: r.carted },
    { event_name: 'purchase_completed', visitors: r.purchased },
  ] });
});

app.get('/v1/stats/retention', requireStatsToken, parseRange, async (req, res) => {
  const { from, to } = res.locals.range;
  const { rows } = await pool.query(
    `WITH firsts AS (
       SELECT anonymous_id, min((occurred_at AT TIME ZONE 'UTC')::date) AS cohort_date
       FROM events
       GROUP BY anonymous_id
       HAVING min((occurred_at AT TIME ZONE 'UTC')::date) BETWEEN $1::date AND $2::date
     ), activity AS (
       SELECT DISTINCT anonymous_id, (occurred_at AT TIME ZONE 'UTC')::date AS active_date
       FROM events
       WHERE occurred_at >= $1::date::timestamp AT TIME ZONE 'UTC'
     )
     SELECT to_char(f.cohort_date, 'YYYY-MM-DD') AS cohort_date,
            (a.active_date - f.cohort_date) AS day_n,
            count(*)::int AS users
     FROM firsts f
     JOIN activity a USING (anonymous_id)
     WHERE a.active_date - f.cohort_date BETWEEN 0 AND 14
     GROUP BY f.cohort_date, day_n
     ORDER BY f.cohort_date, day_n`,
    [from, to],
  );
  res.json({ from, to, cohorts: rows });
});

Each query is the PostgreSQL form of a pattern from the MySQL article: FILTER replaces the CASE trick, and date subtraction yields day offsets directly. The funnel here counts anonymous_id for brevity. Once users log in, switch to the person key idea from the SQL article, or you will count one human twice.

These queries scan the table for each request. That is acceptable at the first million rows, and it falls apart later. The retention query is the heaviest because it scans all history to find first-seen dates. Rollups and indexes are the fix, and a later tuning article covers them.

Errors, health checks and shutdown

Finish the server with an error handler, a health route and a clean exit. Express 5 forwards rejected promises from async handlers to the error handler, so a failed query ends here instead of crashing the process.

app.get('/healthz', async (req, res) => {
  await pool.query('SELECT 1');
  res.json({ ok: true });
});

app.use((err, req, res, next) => {
  if (err.type === 'entity.too.large') return res.status(413).json({ error: 'payload_too_large' });
  if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'invalid_json' });
  console.error(JSON.stringify({ level: 'error', path: req.path, message: err.message }));
  res.status(500).json({ error: 'internal_error' });
});

const server = app.listen(PORT, () => console.log(`analytics API listening on :${PORT}`));

for (const signal of ['SIGINT', 'SIGTERM']) {
  process.on(signal, () => {
    server.close(async () => {
      await pool.end();
      process.exit(0);
    });
  });
}

Never send err.message to the client. Database errors can include table names, column names or fragments of the failing query. Log them, and return a generic 500. The shutdown handler stops accepting connections and lets in-flight requests finish before closing the pool. Flushing a memory buffer on shutdown is a larger topic, handled in the async article.

Run it and send a test event

Create the database, save the schema, and start the server. Then post one event with curl. The shell snippet generates a fresh UUID and a current timestamp, because the validator rejects stale and future times.

createdb acme_analytics
psql acme_analytics -f schema.sql
DATABASE_URL=postgres://localhost/acme_analytics STATS_TOKEN=change-me node server.mjs

curl -i -X POST http://localhost:4000/v1/events \
  -H 'Content-Type: application/json' \
  -H 'Origin: http://localhost:8080' \
  -d "{\"event_id\":\"$(node -e 'console.log(crypto.randomUUID())')\",
       \"event_name\":\"page_view\",
       \"occurred_at\":\"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\",
       \"anonymous_id\":\"a1\",\"session_id\":\"s1\",
       \"page_url\":\"https://acme-shop.example/products/42\"}"

curl -H 'Authorization: Bearer change-me' \
  "http://localhost:4000/v1/stats/pageviews?from=2025-10-01&to=2025-10-07"

You should see 202 with "accepted":1 for the first call and the JSON contract for the second. Send the same event twice, and the second response reports "duplicates":1, which proves the idempotency guard. Then break it on purpose: set event_name to pageview and read the rejection. Testing the failure paths teaches you more than the happy path.

Common bugs you will hit

These are the failures that cost me time the first time I shipped a collector. Check them before you blame the network.

Symptom Likely cause Fix
Browser shows a CORS error, server logs show the request Origin not in the allow-list, or missing Authorization in allowed headers Add the exact origin; list every custom header
Beacons arrive with an empty body Parser ignores text/plain Add text/plain to the parser type list
Everyone gets 429 behind a proxy All requests share the proxy’s IP Set trust proxy to the true hop count
Totals double after client retries No unique key on event_id Primary key plus ON CONFLICT DO NOTHING
Daily numbers shift by one day Local time zone used in a date cast Cast with AT TIME ZONE 'UTC' everywhere
500 on req.body.length Express 5 leaves req.body undefined Check for undefined first

How Real Systems Do This

Plausible exposes a single event endpoint, POST /api/event, and keeps its tracking script tiny. Snowplow separates a lightweight collector from downstream validation and enrichment, so the thing that faces the internet does as little as possible. Both designs follow the same principle you just applied: the public endpoint accepts quickly, and heavier work happens elsewhere.

Your collector does validation inline, which is fine at small scale. When traffic grows, the usual next step is to acknowledge fast and write later through a queue, and the async article builds the first version of that. Naming Kafka or Redis Streams now would be premature.

Decision Framework

  1. Do you need to own the raw events? If not, a hosted analytics product may be cheaper than running a collector.
  2. What is your peak write rate? A single Express process with a pool of ten connections handles modest traffic. Measure with your own load test before you assume more.
  3. Which origins send events? List them, and keep the list in configuration.
  4. Who may read stats? Choose a token, SSO or a private network, and decide before launch.
  5. What happens when PostgreSQL is down? Decide whether to return 503 and let clients retry, or to buffer.
  6. Where will validation rules live? Keep them next to the tracking plan, and version both together.

When NOT to Use This

  • You need analytics this week with no engineering time. Buy a hosted tool. A collector is a service you must patch, monitor and back up.
  • You expect sustained high write rates. A synchronous insert per request will bottleneck on the database. Start with batching and a buffer, or an ingestion service built for volume.
  • You must collect sensitive personal data. This collector assumes none. Health, payment or identity data needs a separate security design, and likely a lawyer.

Common Mistakes

  • Concatenating values into SQL, which turns every event field into an injection point.
  • Skipping the body size limit, which lets one request consume gigabytes of memory.
  • Treating CORS as authentication, which leaves the API open to every script and bot.
  • Accepting free-form event names, which fragments reports and hides typos.
  • Storing the client IP “just in case”, which creates personal data you must now protect and justify.
  • Sending err.message to clients, which leaks database structure.

Key Takeaways

  • Put cheap checks first: CORS, rate limit, size limit, then validation, then the insert.
  • Validate against the tracking plan and build a new object, so unknown fields never reach storage.
  • Use parameterized queries for every value, including inside generated batch inserts.
  • Make retries safe with a client-generated event_id and ON CONFLICT DO NOTHING.
  • Protect reporting endpoints with real authentication, since CORS only constrains browsers.
  • Use UTC day boundaries and half-open ranges in every stats query.
  • Return generic errors, log details, and shut down by draining requests and closing the pool.

FAQ

How do I build an analytics API in Node.js?

Create an Express app with a POST /v1/events route that validates each event, writes it to PostgreSQL with a parameterized insert, and returns 202. Add read-only GET routes for pageviews, funnels and retention behind a token. Protect the collector with a CORS allow-list, a rate limit and a payload size cap.

How do I validate events in an Express analytics collector?

Check every field against the tracking plan: an allow-list of event names, a UUID for event_id, a parsable and recent occurred_at, length limits on strings, and a size cap on properties. Build a new object from the validated fields so unknown keys are dropped.

How do I prevent duplicate events when clients retry?

Have the client generate a UUID per event, make it the primary key, and insert with ON CONFLICT (event_id) DO NOTHING. A retried request then inserts nothing new, and the response can report how many events were duplicates.

Do I need CORS for an analytics collector?

You need it whenever the tracking script runs on a different origin than the collector, which is common. Allow only your own site origins. Remember that CORS controls what browsers expose to pages, so it cannot replace validation, rate limiting or authentication.

Conclusion

A trustworthy analytics API is mostly defensive code around a simple insert. Validate hard, limit early and let the database reject duplicates. With that in place, your dashboard can read honest numbers from day one.

Rule of thumb: the collector trusts nothing, stores only what the tracking plan names, and answers fast.

Last updated on 9 October 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *