The web dev toolbox I actually reach for
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
- The symptom, in the language of the alert
- The likely causes, ordered by probability
- The exact commands to confirm each one
- 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.