Back-End Development

Cut-cost back ends cost more when your team hits 20

A 15-30 person engineering group usually cannot afford “enterprise Node.js” in the way vendors sell it. My position is that doing it properly often means buying less architecture, not more, because premature platform work turns scarce senior attention into cluster maintenance before the product has earned that complexity.

Proper Node.js scale starts with spending constraints, not service boundaries

The budget argument is usually framed badly: cheap means cutting corners, proper means adding microservices, Kubernetes, queues, gateways, and dashboards. I disagree, because the expensive failure in a team this size is rarely raw Node.js throughput; it is unclear ownership, hidden latency, and deploy fear.

The design-pattern catalogue, Node.js API Design Patterns for Scalable Back Ends, gives useful names to API trade-offs, but pattern vocabulary should not outrank a written latency budget because engineers will otherwise debate repositories and controllers while slow database access remains invisible.

For a small-budget Node.js backend, I would define the first “proper” constraint as a service-level target that can be tested locally and in staging. A 300 ms p95 target for common read endpoints is a value to tune, not a universal truth, because a collaborative dashboard and a batch export API deserve different latency budgets. A 1,000 ms max event-loop delay guardrail is also a tunable operating value, because Node.js can look healthy on CPU while users experience stalls from synchronous work.

Use real tools early, but keep the tool count low. Node.js 22 LTS, TypeScript 5.x with strict, Fastify 4.x, PostgreSQL 16, Redis 7, pino 9, OpenTelemetry JS 1.x, Prometheus 2.x, and Grafana 11 are enough for most first scaling problems. Add JSON Schema draft 2020-12 for request contracts and RFC 9457 Problem Details for error responses, because these standards reduce client confusion without requiring a platform team.

I would not split a Node.js backend into microservices “to prepare for scale,” because a 20-engineer organisation usually has fewer deploy coordination problems than data-ownership problems, and microservices make both harder before observability, schema migration, and incident ownership are mature.

A cheap version of this work is one Node.js service with no latency targets, ad hoc logging, manual database changes, and a hope that horizontal scaling will fix everything. A proper small-budget version is still one deployable service, but it has explicit endpoint budgets, reviewed migrations, structured logs, trace IDs, and load-test scripts that run before architectural debate begins.

The cheap path breaks when observability becomes optional

Observability is where small teams often overspend in the wrong direction. A managed APM subscription can be worth the money, but buying it before the team agrees on the signals can create dashboard theatre because nobody knows which chart should block a release.

Start with four signals that affect engineering decisions: p95 latency, error rate, event-loop delay, and database query duration. Prometheus histograms, OpenTelemetry traces, and PostgreSQL pg_stat_statements expose these cheaply because they show where the time goes instead of merely showing that time was spent. A 15 second Prometheus scrape interval is a configuration choice, not a law, because high-cardinality services and low-traffic internal APIs need different storage trade-offs.

After installing pg_stat_statements, one team review should look at the top queries by total time, not only mean time, because a fast query that runs 40,000 times per hour can cost more than a slow query that runs twice. That number is a measured workload property, not a vendor benchmark, and it is more useful than comparing framework leaderboard results.

Here is a small Fastify service that actually enforces pressure limits after installing fastify, @fastify/under-pressure, and pino. It is not architecture; it is a cheap tripwire that prevents a bad release from pretending to be healthy.

import Fastify from 'fastify';
import underPressure from '@fastify/under-pressure';

const app = Fastify({ logger: true });
await app.register(underPressure, {
  maxEventLoopDelay: Number(process.env.MAX_EVENT_LOOP_DELAY_MS || 1000),
  maxHeapUsedBytes: Number(process.env.MAX_HEAP_BYTES || 268435456)
});
app.get('/health', async () => ({ ok: true }));
app.get('/ready', async () => ({ ready: !app.isUnderPressure() }));
await app.listen({ port: Number(process.env.PORT || 3000), host: '0.0.0.0' });

The 268,435,456 byte heap threshold in that snippet is a starting setting to revise under load, because container memory limits, V8 garbage collection, and request payload size change the safe ceiling. Run it with node –enable-source-maps server.mjs, because stack traces without source maps waste time during incidents.

On a small budget, I would use GitHub Actions or GitLab CI to run unit tests, TypeScript checks, database migration verification, and one short load test with autocannon 7.x. A autocannon -c 50 -d 30 run gives 50 concurrent connections for 30 seconds; those are test parameters to tune, because they should reflect your traffic shape rather than someone else’s launch story.

Doing this properly does not mean tracing every function. Set an OpenTelemetry sampling ratio such as 0.1 for normal traffic and raise it during incidents, because full trace capture can create storage cost and noise before it creates insight. Use W3C Trace Context headers, because propagating traceparent across services and jobs makes failures debuggable even before the system becomes distributed.

A modular monolith is usually the honest middle ground

The architecture checklist, Scalable Node.js Architecture and Best Practices for Enterprise, is worth arguing with rather than copying, because enterprise practices only pay off after the organisation can fund the operational habits behind them.

For a 15-30 person engineering organisation, a modular monolith is often the proper choice, because it preserves local reasoning while forcing boundaries in code before forcing boundaries in infrastructure. The budget version is “folders by feature.” The proper version is packages or modules with explicit public APIs, separate database access layers, dependency rules, and ownership in CODEOWNERS.

Use TypeScript project references, ESLint with import/no-restricted-paths, and tsconfig.json composite builds to stop accidental coupling, because social rules alone fail when delivery pressure rises. Use Prisma, Drizzle, or node-postgres deliberately; I prefer node-postgres or Drizzle for teams that already understand SQL, because hiding relational design behind an ORM can delay the moment engineers learn why a query is slow.

API boundaries should be boring. Use OpenAPI 3.1 for external HTTP contracts, JSON Schema draft 2020-12 for validation, OAuth 2.1 or OpenID Connect for delegated auth, and RFC 9457 Problem Details for error bodies. These standards are worth the ceremony because they reduce client-specific behaviour and make compatibility review possible in pull requests.

I would not adopt GraphQL solely to “avoid versioning,” because GraphQL moves compatibility work into schema governance, caching decisions, and resolver performance, while REST with OpenAPI is easier for a small backend team to observe with existing HTTP tooling. GraphQL wins when clients genuinely need flexible selection and the team can fund schema discipline; otherwise it becomes another layer that hides expensive joins.

Queues deserve the same skepticism. Redis Streams, BullMQ, NATS 2.10, and Kafka 3.x are not interchangeable because they encode different delivery and replay assumptions. BullMQ wins for simple background jobs because it is cheap to operate next to Redis; Kafka wins for durable event streams because partitioning, replay, and consumer groups are the product, but it costs more operational attention and usually needs a specialist owner.

The common small-budget mistake is to introduce Kafka before the domain has stable event names, because an event log of confused business concepts becomes a permanent compatibility burden. A proper compromise is PostgreSQL transactional outbox plus a worker process, because it keeps write consistency understandable while leaving a path to NATS or Kafka after event contracts stabilize.

Kubernetes beats a single VM only after coordination is your bottleneck

Here is the explicit comparison I would put in front of the leadership team.

  • Option A: Docker Compose v2 on one or two VMs plus managed PostgreSQL. This wins when traffic is moderate, the team deploys a few times per day, and the main risks are database mistakes and missing observability. The planning cost is often one production VM, one staging VM, managed Postgres, backups, and a few hours per month of maintenance; the trade-off is weaker workload isolation and more manual failover design.
  • Option B: Amazon EKS, Google GKE, or Azure AKS with Kubernetes 1.30. This wins when multiple teams deploy independently, workloads need separate scaling policies, and platform conventions are already funded. The cost is not only cluster nodes and control-plane charges; it is ingress, cert-manager, external-dns, container scanning, Helm chart ownership, upgrade testing, and at least part of a senior engineer’s week.

For many 20-engineer organisations, Option A is more proper for the next year, because it buys reliability work the team will actually perform instead of outsourcing confidence to YAML. Kubernetes is excellent when coordination is the bottleneck, but it is wasteful when the bottleneck is unknown query cost or missing rollback discipline.

If you choose the VM path, still use containers, health checks, immutable images, and Infrastructure as Code with Terraform 1.x or OpenTofu. The cheap version is SSH plus hand-edited Nginx; the proper small-budget version is a repeatable deploy using Docker images, Caddy or Nginx configuration in source control, automatic TLS, and database migrations that can be rolled forward safely.

If you choose Kubernetes, do not pretend it is free because the cluster exists. Set CPU requests such as 250m and memory requests such as 512Mi as initial sizing values to tune, because under-requesting causes noisy-neighbour failures and over-requesting wastes node capacity. Add PodDisruptionBudgets and readiness probes because rolling updates without traffic safety are only redeployments with better branding.

Cost also includes release shape. A monolith deployed ten times a day can be cheaper than eight services deployed once a week, because the first system has a practiced rollback muscle and the second has distributed uncertainty. The proper test is not “are we using modern infrastructure?” but “can we explain the next incident, deploy the fix, and prove the fix worked?”

Your first move is to price the next failure

Before adding services or buying another platform tool, run a two-hour failure pricing session. Pick the slowest endpoint, the riskiest migration, and the noisiest alert; assign each one an owner, a target metric, and a fix budget. If that work feels too basic, do it anyway, because proper Node.js scale starts where the next incident will actually happen.