The web dev toolbox I actually reach for

web-developmenttools

On this page

Most advice about tooling is a popularity contest. This is a different list: the pieces of my setup that I reach for weekly, and the exact reason each one earns its place. Numbers change, but the reasoning stays useful.

Why your Node version keeps betraying you

The most common “it works on my machine” bug is a version gap hiding in plain sight. The runtime you develop against is rarely the runtime you deploy to.

Pin with isolated engines

An .nvmrc file is a hint, not a guard. Your CI reads your package.json engines field before it reads anything else:

{
  "engines": {
    "node": ">=22.12.0"
  }
}

Read engines at deploy time

Render and Vercel both support picking the runtime from engines. Make CI fail loudly when the local and remote versions disagree:

const local = process.versions.node.split(".");
const remote = process.env.NODE_VERSION?.split(".") ?? [];
const [a, b] = local.map(Number);
const [x, y] = remote.map(Number);
if (remote.length && (a !== x || b !== y)) {
  console.error(`Local ${local.join(".")} but remote ${remote.join(".")}`);
  process.exit(1);
}

The dependency update ritual

Updating dependencies is a risk management exercise, not a chore. The routine below keeps most upgrades boring.

One command, no newsletter

The built-in updater only highlights what is actually behind:

npm outdated
npm update --save
npx npm-check-updates -u && npm install

Upgrade in isolation, deploy in waves

  • Patch and minor: same day, one branch, run the whole test suite
  • Major: its own branch, its own PR, changelog review first
  • Framework minors: read the release notes, they usually matter

Why lockfiles beat semver promises

Semver is a promise third parties keep inconsistently. A checked-in lockfile is the only reproducible answer. Treat package-lock.json changes in reviews as first-class diff.

Testing: the pyramid is a ladder

Unit tests are cheap but narrow. Integration tests are expensive but honest. The trick is knowing which layer answers which question.

A unit test that earns its keep

Test the pure logic, not the framework glue:

import { test, assert } from "./helpers";
import { applyDiscount } from "../lib/price";

test("percentage discount floors at zero", () => {
  assert.equal(applyDiscount(50, 120), 0);
});

Integration tests that mirror production

The biggest lie in testing is “it passes locally”. Point an end-to-end run at a real staging deploy, not a mocked server:

BASE_URL=$STAGING_URL npx playwright test

Measuring the fear of breaking things

When your suite takes ten minutes, people stop running it. Set a hard budget: unit tests under a minute, integration under five, and delete slow tests that only assert implementation details.

Deploy previews as a debugging tool

Preview URLs are not just for designers. They are the fastest way to prove a fix against real data before touching production.

The checklist before merging

  • Preview deploys with the exact branch commit
  • Env vars from the pull request, never from local files
  • A smoke test that hits the preview URL automatically
const url = process.env.PREVIEW_URL;
const res = await fetch(`${url}/api/health`);
if (res.status !== 200) process.exit(1);
console.log(`preview healthy: ${url}`);

Diffing invisible changes

Lighthouse bonus: run a performance diff between the base branch and the preview branch to catch regressions that review comments cannot.

Environment variables done right

Secrets are a people problem with an engineering solution.

The three-file pattern

.env.example documents what a newcomer needs. .env.local holds real values, git-ignored. CI injects its own. Nothing else lives in the repo.

DATABASE_URL=postgres://user:pass@localhost:5432/app
STRIPE_SECRET_KEY=sk_test_xxxx
NODE_ENV=development

Validating at boot

Fail fast when a required variable is missing, with a message that tells you which one:

const required = ["DATABASE_URL", "STRIPE_SECRET_KEY"] as const;
export const env = Object.fromEntries(
  required.map((key) => {
    const value = process.env[key];
    if (!value) throw new Error(`Missing required env: ${key}`);
    return [key, value];
  }),
);

Caching is a contract with yourself

Every cache you add is a commitment to invalidate it correctly.

The atlas of caching layers

  • HTTP cache headers: static assets, long-lived
  • Server-side data cache: expensive queries, short-lived
  • Client caches: only for data you control

When a cache makes things slower

If your invalidation logic is longer than the code the cache protects, the cache loses. Measure the hit ratio before celebrating.

Logging like an operator

Logs exist for the night you are not at the laptop. They need to tell a story alone.

Structured over formatted

One JSON line per event beats five pretty-printed paragraphs:

{
  "level": "error",
  "event": "payment.verify_failed",
  "orderId": "u_7fa2",
  "elapsed": 120,
  "retryable": true
}

Correlation ids for free

Generate one id at the request boundary and thread it through every downstream call. That single value turns a pile of logs into a timeline.

The small scripts that save real time

The most underrated tools in any project are the two-line scripts nobody documents.

The project root hero

Run anything from anywhere in the repo by resolving the repo root once:

import { fileURLToPath } from "node:url";
export const root = fileURLToPath(new URL("../", import.meta.url));

A render-friendly date without the timezone maze

const iso = (d = new Date()) =>
  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
    d.getDate(),
  ).padStart(2, "0")}`;

Documentation your future self will love

Write documentation for the person debugging at 3 AM, not the person reading marketing.

The anatomy of a good runbook

  1. The symptom, in the language of the alert
  2. The likely causes, ordered by probability
  3. The exact commands to confirm each one
  4. The rollback, before the fix

A template that works

## Datadog alerts are paging

PagerDuty page → check the dashboard → grab correlation id → search logs.
First, confirm the deploy window. Second, check the error budget.

Reviewing code like a senior

Code review is where most bugs die, if you review the right things.

What actually matters in a diff

Order of importance: correctness under failure, then security, then performance, then style. Style complaints on a Friday are a waste of everyone’s Monday.

The three-pass review

Pass one: read the diff cold for intent. Pass two: trace the failure paths. Pass three: check the tests actually fail when the code is wrong.

Performance budgets you can defend

Budgets beat best-effort. Numbers someone can argue with.

The numbers I ship with

  • First paint under 800 ms on mid-range hardware
  • JavaScript below 200 KB compressed on first load
  • Interaction to next paint under 200 ms

Where to put the hard numbers

Keep budgets in CI as a hard gate, not a dashboard nobody reads:

- name: Check budgets
  run: npx lhci autorun --collect.url=$URL --assert.budgets=budgets.json

Accessibility as a feature, not a favor

Inclusive defaults are cheaper than retrofits. The baseline costs almost nothing.

The five checks I never skip

  • Every image has a real alt text, not empty air
  • Focus is visible and travels in a sensible order
  • Contrast passes at the smallest text size used
  • Forms label everything, even placeholders do not count
  • Motion and autoplay can be paused

Keyboard-first triage

If a task takes more clicks with a keyboard than a mouse, it is an accessibility bug, and it will hurt your real users long before it hurts your test suite.

Shipping small, shipping often

The best deployment is a boring one. Batch features into small, frictionless releases.

The lease on your main branch

Never let a feature live more than a week without deploying. Long-lived branches rot; short-lived ones stay green.

Releasing behind a flag

Ship code before you ship behaviour. A flag gets you the ability to turn a bad change off in seconds, which is the entire goal:

const flags = new Set(process.env.FEATURE_FLAGS?.split(",") ?? []);
export const enabled = (name: string) => flags.has(name);

What I stopped doing

The most valuable tooling work is deletion.

  • Manual deploy checklists replaced by a predeploy script
  • Custom auth wrappers replaced by the platform’s built-in sessions
  • Weekly dependency audits replaced by automated patching in CI
  • Twenty-line config files replaced by the two-line default

The setup that runs itself

Everything above collapses into one quiet loop: CI runs tests, previews deploy per branch, budgets gate merges, and the release is a label, not a ceremony.

It is not exciting. That is the point. Boring tooling frees attention for the interesting problems, which is exactly where you want to spend it.