Contact
EngineeringOct 19, 2026·30 min read

Monolith vs Microservices for a Startup: Start With One, Split on Evidence

KT
Keplaris TeamOct 19, 2026
Illustration of a black monolith with four glass module compartments, one module lifted out into its own box and wired into a tangled network of small service boxes

For a startup with a handful of engineers, the answer to monolith vs microservices is to start with one deployable, a monolith with clear internal module boundaries, and to extract a service only when a specific, measurable signal says so. A service boundary turns function calls into network calls, one deploy into coordinated releases, and one database transaction into a distributed consistency problem, and a small team pays those costs long before it gets the benefits.

To put a number on the first of those costs, we wrote a small Node.js benchmark and ran it on 2026-10-19. The same entitlement check took about 0.1 microseconds as an in-process call, 72 microseconds at the median as an HTTP call over a reused local connection, and 269 microseconds through a chain of three services. Those are loopback floors, small in absolute terms. The more telling result came from the failure test: when each response had a 1% chance of arriving after the caller's timeout, plain retries wrote 16 to 21 duplicate invoices per 2,000 calls, and only an idempotency key brought that to zero. The full script is at the end; it needs nothing but Node.

Our default on API and SaaS development work is a monolith we can decompose later. This post explains that stance with the benchmark, the split signals, and the cases everyone cites, quoted with their own caveats.

The short answer: one deployable until a signal says split

Build one deployable first, keep its modules separated inside the codebase, and treat every new service as a decision that needs evidence. Martin Fowler put the pattern plainly in Monolith First (June 2015): "Almost all the successful microservice stories have started with a monolith that got too big and was broken up." He adds that in almost all the cases he had heard of where a system was built as microservices from scratch, "it has ended up in serious trouble." In Microservice Premium (May 2015) his guideline is blunt: "don't even consider microservices unless you have a system that's too complex to manage as a monolith." Both are a practitioner's observations, not studies, but they match what the case write-ups below describe.

The verdict is wrong for three kinds of startup: one that must keep a regulated part of the system (card data, health records) in a small, separately audited scope; one with a component whose load or hardware needs are nothing like the rest; and one that already has separate teams who ship on separate schedules. All three are covered under when microservices make sense early, below.

The limits of this advice. The benchmark measures only the floor cost of a hop on one machine (its full caveats sit next to the results). The case evidence is a handful of self-reported company posts plus expert opinion, not controlled studies, and no study links team size to the right architecture. A monolith has real weaknesses too: one bad deploy can take down everything, fault isolation is weaker (Segment lists it first among its own trade-offs), and scaling one hot component means scaling all of it. This post uses no client project data.

What actually changes when you split: deploys, data, and failure modes

Splitting a monolith changes the deploy unit, the transaction boundary and the ways a request can fail, and each of those changes adds work before it adds benefit. Here is the comparison for a small SaaS team.

One deployable (monolith)Several services (microservices)
Deploy unitOne artifact, one pipeline, one rollbackOne pipeline per service; cross-service changes need ordered releases
TransactionsOne database transaction covers the whole requestNo shared transaction; you need an outbox, a saga or compensating actions
Local developmentOne process plus a databaseEvery service you touch, or mocks of them, plus their data
DebuggingOne stack trace, one log streamDistributed tracing to follow one request across processes
Failure modesA call either returns or throwsTimeouts, partial failures, retries, duplicates, slow dependencies
Contracts between partsFunction signatures the compiler or tests checkNetwork APIs you have to version and deprecate like a public API
Ops surfaceOne service to monitor, scale and patchN services, each with health checks, alerts, dashboards and dependency updates

One request traced through both

Take a SaaS "create invoice" request that does three things: checks that the tenant's plan allows invoicing, writes the invoice row, and queues the email.

In a monolith those are three function calls inside one database transaction. If the insert fails, the transaction rolls back and nothing was half done. If anything throws, you get one stack trace. Roughly:

// Monolith: three function calls, one transaction (illustrative)
await db.transaction(async (tx) => {
  const ent = entitlements.check({ tenantId, feature: "invoices" });
  if (!ent.allowed) throw new PlanLimitError();
  const invoice = await invoices.create(tx, { tenantId, amount });
  await outbox.enqueue(tx, "invoice.email", { invoiceId: invoice.id });
});

Split the same request across an entitlement service, an invoice service and an email service and it picks up two network hops. Each hop needs a timeout and a retry policy. The "create" call needs an idempotency key, because a retried create that the server already processed would otherwise bill twice. And there is no longer one transaction around the three steps, so the email has to come from an outbox or a saga that tolerates the invoice existing before the email is queued. When the entitlement service times out, the invoice service has to choose between failing the request and guessing, and the caller sees an error that started two processes away.

// Split: what the caller now owns for one step (illustrative)
const ent = await withRetry(
  () => http.post(ENTITLEMENTS_URL, { tenantId, feature: "invoices" }, { timeoutMs: 500 }),
  { attempts: 3, backoff: "exponential+jitter" }
);
const invoice = await withRetry(
  () => http.post(INVOICES_URL, { tenantId, amount }, {
    timeoutMs: 500,
    headers: { "Idempotency-Key": requestId }, // or a retry can double-bill
  }),
  { attempts: 3, backoff: "exponential+jitter" }
);

The benchmark below measures the floor of those hops (case D) and what happens to the create call when some responses are slow (case E).

We measured what one service boundary costs at the call level

On one machine, the same function cost about 0.1 microseconds in-process, 72 microseconds over a reused local HTTP connection and 269 microseconds through three services, all at the median. The function is an entitlement check: it takes { tenantId, feature } and returns { allowed, limit } from an in-memory map of 1,000 tenants. Every path runs the same function; the HTTP paths add JSON encoding, a request and a response. Each service is a separate Node process (started with child_process.fork), so caller and callee don't share an event loop.

  • A. In-process function call.
  • B. HTTP to 127.0.0.1 with a keep-alive agent (one reused connection).
  • C. HTTP with a new TCP connection per call.
  • D. A chain: the caller calls service A, which calls B, which calls the entitlement service. Three sequential hops, all keep-alive.
  • E. Failure injection on a "create invoice" endpoint, described below the table.

Every case ran serially (one call at a time) after a warm-up. Iterations per run: A 100,000 (warm-up 10,000), B 10,000 (1,000), C 2,000 (200), D 5,000 (500), E 2,000 per variant. The script runs the whole suite three times with fresh server processes and reports the median of the three runs for each figure.

Setup: Apple M1 Max (10 cores, 64 GB RAM), macOS 27.0.1 (Darwin 27.0.0, arm64), Node v24.20.0, run on 2026-10-19.

Casep50p95p99Serial calls per second
A. In-process call0.13 µs0.17 µs0.21 µs16,683,141
B. HTTP, keep-alive71.71 µs99.13 µs125.79 µs13,226
C. HTTP, new connection per call156.13 µs226.29 µs331.67 µs5,889
D. Three sequential HTTP hops268.50 µs384.29 µs470.33 µs3,497

The in-process figures are close to the cost of reading the clock twice, so treat A as "about a tenth of a microsecond" rather than a precise value; its throughput comes from a separate untimed loop of 1,000,000 calls. Even so, a single local hop is hundreds of times slower than a function call at the median, a fresh connection per call roughly doubles it, and the three-hop chain cost about 3.7 times a single hop, a little worse than three hops added up (we didn't isolate why). The unedited terminal output, with each run's figures, is in the last section next to the script.

The failure test: where a boundary really costs you

Case E is the part a monolith has no equivalent for. The invoice service writes the invoice, then, for a seeded 1% chance per request, waits 1,000 ms before answering. The caller gives up after 500 ms. We ran three callers, 2,000 calls each, three times:

CallerCalls that failedInvoices written for 2,000 callsDuplicates
No retry17, 21, 202,000 every run0
Retry with backoff, no idempotency key02,021, 2,020, 2,01621, 20, 16
Retry with backoff and an idempotency key02,000 every run0

Read the first row closely: every call the caller counted as failed had in fact created its invoice. A timeout tells you that you didn't hear back, not that the work didn't happen. Adding retries hides the errors and creates a duplicate for every timeout. Only the retry with an idempotency key (the server remembers the key and returns the first result) gets both no failures and no duplicates, and even then about 1% of calls took over 400 ms because they sat through a timeout and a backoff. In a monolith the same create is a function call inside a transaction: it returns or it throws, and a rollback leaves nothing behind. The receiving side has the mirror problem when a provider delivers the same webhook twice; we tested four receiver designs for that in our webhook idempotency and retries guide. The retry and backoff patterns are the same ones we cover for per-tenant rate limits, and they now apply between your own services too.

Availability multiplies along a chain

This part is arithmetic, not measurement. If a request depends synchronously on several services, its availability is the product of theirs: A = a₁ × a₂ × … × aₙ. Three services at 99.9% each give 0.999³ ≈ 0.997, or about 99.7%, before counting the network between them. Each synchronous hop you add lowers the ceiling.

What this benchmark does not show

  • Loopback is the floor. There is no TLS, no real network, no cross-zone latency, no load balancer, no service mesh, and the payloads are a few dozen bytes. Production hops cost more, and how much more depends on your setup.
  • Calls ran one at a time. Under concurrency, queueing changes the tail, and we didn't test it.
  • It doesn't measure the costs that usually decide this question: coordinating releases, debugging across services and keeping data consistent.
  • The failure model is simple on purpose: a fixed 1% chance of a 1,000 ms delay. Real failures cluster, and a retry storm can make an outage worse.

So the honest takeaway is not that 72 microseconds will sink your product. It is that the per-call cost is the smallest part of a boundary, and the failure handling around it is work a monolith never asks you to do.

Why microservices slow down a small team

Microservices slow a small team because the operations and coordination work grows with the number of services, while the number of people stays the same. Every engineer becomes part-time on-call for several services. Local development means running several processes or maintaining mocks of them. A feature that touches two services becomes two pull requests, two deploys and a release order, and a schema change becomes a contract change.

Segment's write-up is the clearest public account of this. In Goodbye Microservices (July 2018), Alexandra Noonan describes the point where the team had "3 full-time engineers spending most of their time just keeping the system alive," and notes that "our operational overhead increased linearly with each added destination." The fix was "to consolidate the now over 140 services into a single service." After the move, the team made 46 improvements to its shared libraries in a year, against 32 while the microservice architecture was in place. That is one company's self-reported account, and the services were split for a real reason (to stop one failing destination from blocking the queue for all of them), which is worth remembering before treating it as a verdict on microservices in general.

There is no study that sets a team size at which microservices start to pay. Fowler's own observation shows the spread: "I've seen microservice systems vary from a team of 60 with 20 services to a team of 4 with 200 services." Any cutoff you read is opinion. Use signals you can measure instead, listed further down.

The distributed monolith: microservices' costs without their benefits

A distributed monolith is a set of services that must deploy together, share a database, or call each other synchronously in chains, so you pay for network calls and extra operations without getting independent deploys or fault isolation. It is the most common way premature microservices go wrong, because it looks like a service architecture on a diagram and behaves like one program in production.

Shared libraries are a typical cause. Segment's account shows the mechanism: "Testing and deploying changes to these shared libraries impacted all of our destinations," and "Making changes to improve our libraries, knowing we'd have to test and deploy dozens of services, was a risky proposition." Then: "Over time, the versions of these shared libraries began to diverge across the different destination codebases." Services that are supposed to be independent end up coupled through code they all have to upgrade in lockstep.

Three checks tell you whether you have one:

  • Deploys. Can you release one service without releasing another? If a change usually needs two or more services deployed in order, the boundary is in the wrong place.
  • Data. Do two services read or write the same tables? Then they share a schema, and a migration is a coordinated release.
  • Calls. Does a user request wait on a chain of synchronous calls? Case D above is the floor cost of three; the failure test shows what each one adds to the error handling.

When microservices make sense early

Microservices make sense early in four situations, and in two of them a separate process from the same codebase is often enough.

  1. Compliance or security isolation. If one part handles card data or other regulated data, putting it in a small, separate service can keep the audited scope small and keep the rest of the system out of it. This is the strongest early reason.
  2. A very different scaling profile. A component whose CPU, memory or traffic pattern differs sharply from the rest, such as video processing next to a CRUD app, can justify its own deploy so you scale it alone.
  3. A different runtime. A GPU inference worker or a Python ML job next to a Node app needs its own process, image and hardware anyway.
  4. Genuinely independent teams. When separate teams own separate parts and want separate release schedules, service boundaries can follow team boundaries. A startup with one team rarely has this.

For cases 2 and 3, a worker process or queue consumer built from the same codebase usually covers it: same repository, same models, deployed as a second process that pulls jobs from a queue. You get separate scaling and a separate runtime where needed without network calls in the request path.

One thing that isn't microservices: giving a large tenant its own database. That is a data-isolation choice (the silo model), and it can run from the same single codebase. We cover it in multi-tenant SaaS architecture.

Keep the monolith splittable

A monolith stays splittable when its modules talk only through public interfaces and never reach into each other's tables. Three rules do most of the work: give each module one public interface (a set of functions other modules may call), keep each table owned by one module and accessed only through that module, and check those rules in CI so they don't erode. If you later extract a module, its public interface becomes the service API and its tables move with it.

Shopify is the best-documented example. In Deconstructing the Monolith (February 2019), Kirsten Westeinde wrote: "We chose to evolve Shopify into a modular monolith, meaning that we would keep all of the code in one codebase," adding that while microservices would address the problems they had, "they'd bring another whole suite of problems." In Under Deconstruction (September 2020), Philip Müller reported "over 2.8 million lines of Ruby code and 500,000 commits," 37 components in the main monolith, and a tool they built, Packwerk, "to analyze static constant references." He also wrote that "Packwerk is used on about a third of the components to restrict their dependencies," so even at Shopify the boundaries were partly enforced, years into the effort.

Signals it is time to extract your first service, and the ones that are not

Extract a service when you can point to a measured problem that a separate deploy would fix. These are the signals worth tracking:

  • Deploy contention. Time spent waiting in the deploy queue, or merge conflicts in the same files between people working on unrelated features.
  • Change locality. Your git history shows one module changing on its own schedule, rarely in the same commits as the rest. That module is a candidate; one that changes together with three others is not.
  • A diverging resource profile. Your APM shows one module's CPU or memory use, or its traffic pattern, pulling away from everything else.
  • Release cadence conflicts. One part needs to ship several times a day while another needs a slower, more careful release.
  • A compliance scope you need to shrink. An audit would be smaller and cheaper if one module lived on its own.

And these look like reasons but are not:

  • "The code is messy." That calls for a refactor. Moving messy code behind a network call keeps the mess and adds a hop.
  • A slow test suite. Fix the suite: parallelize it, split slow integration tests out, or stop hitting real services in unit tests.
  • A hot database table. That is a data problem. A new service would still read the same table, or need a copy you then have to keep in sync.
  • "We need to scale" without a profile showing what is slow. A stateless deployable can usually run as more copies behind a load balancer.
  • Hiring or résumé appeal. A real effect on a job post, and a poor reason to triple your operations work.

If you are past product-market fit and planning the migration itself, our guide to re-architecting a SaaS after product-market fit covers refactor vs rebuild and the order of operations.

What it costs, using published prices only

Every service adds fixed line items per environment, so the bill grows with services times environments, before any traffic. Using prices read on 2026-10-19: Render's pricing page listed web service instances at $7 a month for 0.5 CPU and 512 MB RAM and $25 a month for 1 CPU and 2 GB RAM. Amazon's EKS pricing page lists the Kubernetes control plane at "$0.10 per cluster per hour" for standard version support ($0.60 for extended support). At 730 hours a month that is $73 per cluster, before any worker nodes. The table is our arithmetic on those prices for a production and a staging environment:

Fixed monthly line item (arithmetic)1 service5 services
Render, $7 instance, 2 environments$14$70
Render, $25 instance, 2 environments$50$250
EKS control plane only, one cluster per environment (2 × $73)$146$146

The table leaves out monitoring, logging and CI, because those vendors price per host, per container, per seat or per volume, and you should read the page for the vendor you actually use. Monitoring that charges per container or host grows with service count in the same way. The largest cost, engineering time spent on the work in the sections above, has no published figure, and we won't invent one.

The case most often cited on cost is Prime Video, and it is usually quoted too broadly. DevClass reported in May 2023 on a Prime Video Tech post about one service, its audio and video monitoring tool. It had been built as distributed components orchestrated by AWS Step Functions, which performed several state transitions for every second of each stream (billed per transition), and it made a high number of calls to S3 to store captured video frames temporarily. The team moved the components into a single process running on EC2 and ECS, and wrote: "Moving our service to a monolith reduced our infrastructure cost by over 90%." The same post said the choice "has to be made on a case-by-case basis." The original Prime Video Tech URL now redirects to an Amazon corporate page, so DevClass is our source here. One service's infrastructure bill, driven by per-transition and per-request charges, is not a verdict on microservices.

Does an MVP or serverless change the answer?

An MVP makes the case for one deployable stronger, and serverless changes how you deploy, not whether you have a monolith. For an MVP the goal is to learn what customers will pay for with the least code, and every service boundary is code and operations spent on something other than that. Scope comes first; our guide to scoping a SaaS MVP covers what to cut.

Serverless functions are a deployment model, not automatically microservices. One codebase deployed as functions behind a gateway, sharing one data model and released together, is still one unit. It becomes a distributed system when many small functions call each other or are chained through an orchestrator, which is the shape of the Prime Video monitoring service described above.

If your first version came out of an AI coding tool and works as a demo, the question is usually hardening rather than architecture; see taking a vibe-coded prototype to production.

The full benchmark script

Save it as hop-bench.mjs in an empty directory and run node hop-bench.mjs. It uses only Node built-ins (no npm install), starts its own server processes on random local ports, takes two to three minutes, and prints every number in this post. Your numbers will differ with hardware and load; the ratios between cases are the useful part.

The unedited output from the run on 2026-10-19 ("1 in 100" in case E is a seeded 1% chance per response, so the realized count varies by run):

Machine: Apple M1 Max, 10 cores, 64 GB RAM
OS: Darwin 27.0.0 (darwin arm64), Node v24.20.0
Date: 2026-10-19T05:44:16.864Z
Iterations per run: A 100,000 (warm-up 10,000), B 10,000 (1,000), C 2,000 (200), D 5,000 (500), E 2,000 per variant

Latency in microseconds, median of 3 runs (each run's p50/p95/p99 shown in brackets)
case                                       p50       p95       p99  serial calls/s
A. in-process function call               0.13      0.17      0.21      16,683,141
                                     runs: [0.1/0.1/0.2] [0.1/0.2/0.2] [0.1/0.2/0.3]
B. HTTP, keep-alive                      71.71     99.13    125.79          13,226
                                     runs: [71.6/90.8/122.0] [76.4/112.0/138.1] [71.7/99.1/125.8]
C. HTTP, new connection per call        156.13    226.29    331.67           5,889
                                     runs: [156.1/226.3/331.7] [165.9/250.0/378.8] [154.4/203.7/277.0]
D. 3 sequential HTTP hops               268.50    384.29    470.33           3,497
                                     runs: [268.0/384.3/470.3] [307.5/486.9/733.4] [268.5/372.0/462.2]

E. create invoice, 1 in 100 responses delayed 1,000 ms (seeded), client timeout 500 ms, retry variants make up to 2 retries; one bracket per run:
  no retry                    [failed 17/2000, invoices written 2000, duplicates 0, calls over 400 ms 17, slowest 504 ms] [failed 21/2000, invoices written 2000, duplicates 0, calls over 400 ms 21, slowest 502 ms] [failed 20/2000, invoices written 2000, duplicates 0, calls over 400 ms 20, slowest 502 ms]
  retry, no idempotency key   [failed 0/2000, invoices written 2021, duplicates 21, calls over 400 ms 21, slowest 603 ms] [failed 0/2000, invoices written 2020, duplicates 20, calls over 400 ms 20, slowest 603 ms] [failed 0/2000, invoices written 2016, duplicates 16, calls over 400 ms 16, slowest 602 ms]
  retry + idempotency key     [failed 0/2000, invoices written 2000, duplicates 0, calls over 400 ms 24, slowest 602 ms] [failed 0/2000, invoices written 2000, duplicates 0, calls over 400 ms 23, slowest 603 ms] [failed 0/2000, invoices written 2000, duplicates 0, calls over 400 ms 21, slowest 632 ms]
// hop-bench.mjs: what one service boundary costs at the call level.
// The same entitlement check called in-process, over HTTP on 127.0.0.1, and through a
// 3-hop chain, plus a failure-injection run for a "create invoice" call.
// Node built-ins only. Run: node hop-bench.mjs
import http from "node:http";
import { fork } from "node:child_process";
import os from "node:os";

const ROLE = process.argv[2];

// ---------- The unit under test: same code for every path ----------
const PLANS = new Map();
for (let t = 1; t <= 1000; t++) {
  const plan = t % 10 === 0 ? "pro" : "free";
  PLANS.set(`t${t}`, {
    plan,
    features: plan === "pro" ? { exports: 10000, seats: 50, api: 100000 } : { exports: 10, seats: 3, api: 1000 },
  });
}
function checkEntitlement({ tenantId, feature }) {
  const p = PLANS.get(tenantId);
  if (!p) return { allowed: false, limit: 0 };
  const limit = p.features[feature] ?? 0;
  return { allowed: limit > 0, limit };
}

// Seeded PRNG (mulberry32) so failure injection is repeatable.
function rng(seed) {
  return () => {
    seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function readBody(req) {
  return new Promise((resolve) => {
    let s = "";
    req.on("data", (c) => (s += c));
    req.on("end", () => resolve(s));
  });
}

function postJSON(port, path, obj, agent, timeoutMs, headers = {}) {
  return new Promise((resolve, reject) => {
    const body = JSON.stringify(obj);
    const req = http.request(
      { host: "127.0.0.1", port, path, method: "POST", agent,
        headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body), ...headers } },
      (res) => {
        let s = "";
        res.on("data", (c) => (s += c));
        res.on("end", () => resolve(JSON.parse(s)));
      }
    );
    if (timeoutMs) req.setTimeout(timeoutMs, () => req.destroy(new Error("timeout")));
    req.on("error", reject);
    req.end(body);
  });
}

// ---------- Harness ----------
function start(role, arg) {
  return new Promise((resolve) => {
    const child = fork(new URL(import.meta.url).pathname, [role, String(arg ?? "")]);
    child.once("message", (m) => resolve({ child, port: m.port }));
  });
}

function pct(sortedNs, p) {
  const i = Math.min(sortedNs.length - 1, Math.ceil((p / 100) * sortedNs.length) - 1);
  return Number(sortedNs[i]) / 1000; // microseconds
}

async function measure(warmup, iters, fn) {
  for (let i = 0; i < warmup; i++) await fn(i);
  const t = new Array(iters);
  const t0 = process.hrtime.bigint();
  for (let i = 0; i < iters; i++) {
    const a = process.hrtime.bigint();
    await fn(i);
    t[i] = process.hrtime.bigint() - a;
  }
  const total = Number(process.hrtime.bigint() - t0) / 1e9;
  t.sort((x, y) => (x < y ? -1 : x > y ? 1 : 0));
  return { p50: pct(t, 50), p95: pct(t, 95), p99: pct(t, 99), perSec: iters / total };
}

function measureSync(warmup, iters, fn) {
  for (let i = 0; i < warmup; i++) fn(i);
  const t = new Array(iters);
  const t0 = process.hrtime.bigint();
  for (let i = 0; i < iters; i++) {
    const a = process.hrtime.bigint();
    fn(i);
    t[i] = process.hrtime.bigint() - a;
  }
  const total = Number(process.hrtime.bigint() - t0) / 1e9;
  t.sort((x, y) => (x < y ? -1 : x > y ? 1 : 0));
  return { p50: pct(t, 50), p95: pct(t, 95), p99: pct(t, 99), perSec: iters / total };
}

const req = (i) => ({ tenantId: `t${(i % 1000) + 1}`, feature: i % 3 === 0 ? "exports" : "seats" });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function oneRun(run) {
  const results = {};
  let sink = 0;

  // A. In-process call
  results.A = measureSync(10000, 100000, (i) => { sink += checkEntitlement(req(i)).limit; });
  // Per-call timing above is near the timer's own cost, so take A's throughput from an untimed loop.
  {
    const t0 = process.hrtime.bigint();
    for (let i = 0; i < 1000000; i++) sink += checkEntitlement(req(i)).limit;
    results.A.perSec = 1000000 / (Number(process.hrtime.bigint() - t0) / 1e9);
  }

  const ent = await start("entitlement", 20261019 + run);

  // B. HTTP, keep-alive agent (one reused connection)
  const keep = new http.Agent({ keepAlive: true, maxSockets: 1 });
  results.B = await measure(1000, 10000, async (i) => { sink += (await postJSON(ent.port, "/check", req(i), keep)).limit; });
  keep.destroy();

  // C. HTTP, new TCP connection per call
  const noKeep = new http.Agent({ keepAlive: false });
  results.C = await measure(200, 2000, async (i) => { sink += (await postJSON(ent.port, "/check", req(i), noKeep)).limit; });

  // D. 3 sequential hops: caller to A, A to B, B to the entitlement service (all keep-alive)
  const b = await start("relay", ent.port);
  const a = await start("relay", b.port);
  const chainAgent = new http.Agent({ keepAlive: true, maxSockets: 1 });
  results.D = await measure(500, 5000, async (i) => { sink += (await postJSON(a.port, "/check", req(i), chainAgent)).limit; });
  chainAgent.destroy();
  a.child.kill(); b.child.kill();

  // E. Failure injection on "create invoice": 1 in 100 responses delayed 1,000 ms, client timeout 500 ms.
  const N = 2000;
  const eAgent = new http.Agent({ keepAlive: true });
  const backoff = rng(7);
  async function createInvoice(i, { retry, idempotent }) {
    const key = idempotent ? `inv-${run}-${i}` : undefined;
    const headers = key ? { "idempotency-key": key } : {};
    for (let attempt = 0; attempt <= (retry ? 2 : 0); attempt++) {
      try {
        return await postJSON(ent.port, "/invoice", { tenantId: req(i).tenantId, amount: 4900 }, eAgent, 500, headers);
      } catch (err) {
        if (attempt === (retry ? 2 : 0)) throw err;
        await sleep(50 * 2 ** attempt + Math.floor(backoff() * 50)); // exponential backoff with jitter
      }
    }
  }
  results.E = {};
  for (const [name, opts] of [
    ["no retry", { retry: false, idempotent: false }],
    ["retry, no idempotency key", { retry: true, idempotent: false }],
    ["retry + idempotency key", { retry: true, idempotent: true }],
  ]) {
    await postJSON(ent.port, "/reset", {}, eAgent);
    let failed = 0;
    const times = [];
    for (let i = 0; i < N; i++) {
      const t = process.hrtime.bigint();
      try { await createInvoice(i, opts); } catch { failed++; }
      times.push(process.hrtime.bigint() - t);
    }
    await sleep(1200); // let late responses finish so every write is counted
    const { invoices } = await postJSON(ent.port, "/count", {}, eAgent);
    const slow = times.filter((t) => t > 400_000_000n).length;
    const maxMs = Number(times.reduce((m, t) => (t > m ? t : m), 0n)) / 1e6;
    results.E[name] = { calls: N, failed, invoices, duplicates: invoices - N, slow, maxMs };
  }
  eAgent.destroy();
  ent.child.kill();
  results.sink = sink;
  return results;
}

async function main() {
  const RUNS = 3;
  const cpu = os.cpus()[0].model;
  console.log(`Machine: ${cpu}, ${os.cpus().length} cores, ${Math.round(os.totalmem() / 2 ** 30)} GB RAM`);
  console.log(`OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()}), Node ${process.version}`);
  console.log(`Date: ${new Date().toISOString()}`);
  console.log(`Iterations per run: A 100,000 (warm-up 10,000), B 10,000 (1,000), C 2,000 (200), D 5,000 (500), E 2,000 per variant\n`);

  const runs = [];
  for (let r = 0; r < RUNS; r++) {
    runs.push(await oneRun(r));
    await sleep(2000);
  }
  const med = (xs) => xs.slice().sort((x, y) => x - y)[Math.floor(xs.length / 2)];
  const labels = {
    A: "A. in-process function call",
    B: "B. HTTP, keep-alive",
    C: "C. HTTP, new connection per call",
    D: "D. 3 sequential HTTP hops",
  };
  console.log(`Latency in microseconds, median of ${RUNS} runs (each run's p50/p95/p99 shown in brackets)`);
  console.log(`${"case".padEnd(36)} ${"p50".padStart(9)} ${"p95".padStart(9)} ${"p99".padStart(9)} ${"serial calls/s".padStart(15)}`);
  for (const k of ["A", "B", "C", "D"]) {
    const m = (f) => med(runs.map((r) => r[k][f]));
    console.log(`${labels[k].padEnd(36)} ${m("p50").toFixed(2).padStart(9)} ${m("p95").toFixed(2).padStart(9)} ${m("p99").toFixed(2).padStart(9)} ${Math.round(m("perSec")).toLocaleString("en-US").padStart(15)}`);
    console.log(`${"".padEnd(36)} runs: ${runs.map((r) => `[${r[k].p50.toFixed(1)}/${r[k].p95.toFixed(1)}/${r[k].p99.toFixed(1)}]`).join(" ")}`);
  }
  console.log(`\nE. create invoice, 1 in 100 responses delayed 1,000 ms (seeded), client timeout 500 ms, retry variants make up to 2 retries; one bracket per run:`);
  for (const name of Object.keys(runs[0].E)) {
    console.log(`  ${name.padEnd(27)} ` + runs.map((r) => {
      const e = r.E[name];
      return `[failed ${e.failed}/${e.calls}, invoices written ${e.invoices}, duplicates ${e.duplicates}, calls over 400 ms ${e.slow}, slowest ${Math.round(e.maxMs)} ms]`;
    }).join(" "));
  }
}

// ---------- Server processes ----------
if (ROLE === "entitlement") {
  // POST /check: the entitlement check. POST /invoice: a write, 1 in 100 responses delayed 1,000 ms
  // AFTER the write (the work is done, the caller just doesn't hear back in time).
  const rand = rng(Number(process.argv[3] || 1));
  const invoices = new Map(); // id -> invoice
  const seenKeys = new Map(); // idempotency key -> invoice id
  let nextId = 1;
  const server = http.createServer(async (req, res) => {
    const input = JSON.parse((await readBody(req)) || "{}");
    if (req.url === "/check") {
      res.setHeader("content-type", "application/json");
      return res.end(JSON.stringify(checkEntitlement(input)));
    }
    if (req.url === "/invoice") {
      const key = req.headers["idempotency-key"];
      let id;
      if (key && seenKeys.has(key)) id = seenKeys.get(key);
      else { id = nextId++; invoices.set(id, input); if (key) seenKeys.set(key, id); }
      const delay = rand() < 0.01 ? 1000 : 0;
      return setTimeout(() => res.end(JSON.stringify({ id })), delay);
    }
    if (req.url === "/count") return res.end(JSON.stringify({ invoices: invoices.size }));
    if (req.url === "/reset") { invoices.clear(); seenKeys.clear(); nextId = 1; return res.end("{}"); }
    res.statusCode = 404; res.end("{}");
  });
  server.keepAliveTimeout = 60000;
  server.listen(0, "127.0.0.1", () => process.send({ port: server.address().port }));
} else if (ROLE === "relay") {
  // A service that receives a request and calls the next service before answering.
  const next = Number(process.argv[3]);
  const agent = new http.Agent({ keepAlive: true, maxSockets: 1 });
  const server = http.createServer(async (req, res) => {
    const input = JSON.parse(await readBody(req));
    const out = await postJSON(next, "/check", input, agent);
    res.setHeader("content-type", "application/json");
    res.end(JSON.stringify(out));
  });
  server.keepAliveTimeout = 60000;
  server.listen(0, "127.0.0.1", () => process.send({ port: server.address().port }));
} else {
  await main();
}

If you're deciding how to structure a new SaaS, or whether your monolith has a module worth extracting, that call is part of the Keplaris API and SaaS development work: one deployable to start, with the boundaries in place for the day a signal says split.

Frequently asked questions

Should a startup use microservices?

Usually not at the start. With a handful of engineers, one deployable with clear internal module boundaries ships faster, is easier to debug, and keeps transactions simple. Martin Fowler's guideline is to not even consider microservices unless the system is too complex to manage as a monolith. The exceptions are specific: a compliance scope you need to keep small, one component with a very different scaling profile, a different runtime such as a GPU worker, or genuinely independent teams. Extract a service when one of those, or a measured signal like deploy contention, shows up.

How many engineers do you need before microservices make sense?

No study sets a number, and any cutoff you read (10, 50, 100 engineers) is opinion. Martin Fowler has written that he has seen microservice systems range from a team of 60 with 20 services to a team of 4 with 200 services, which shows how loosely team size and service count relate. Decide on signals you can measure instead: teams waiting on each other to deploy, changes that keep landing in the same module from different teams, or one component whose resource profile differs sharply from the rest.

What is a distributed monolith?

A system split into services that still behave like one program: they have to deploy together, share a database, or call each other synchronously in long chains. You pay the network, operations and debugging costs of microservices without getting independent deploys or fault isolation. A common cause is shared libraries that every service must update in lockstep. Segment described exactly that: changes to shared libraries meant testing and deploying dozens of services, and over time the library versions diverged across codebases.

Is serverless the same as microservices?

No. Serverless is a deployment model; microservices is a way of dividing a system into independently deployed parts. One codebase deployed as functions behind an API gateway is still one unit if it ships together and shares a data model. It becomes a microservice system when separately owned functions call each other over the network. The Prime Video monitoring case that DevClass reported orchestrated many components with AWS Step Functions, and per-transition Step Functions charges plus frequent S3 calls were the cost problems the team named.

Can you go back from microservices to a monolith?

Yes, and some teams have written about doing it. Segment wrote in 2018 that it had consolidated over 140 services into a single service, after reporting that three full-time engineers spent most of their time keeping the system alive. It also listed the trade-offs it accepted: fault isolation became harder, in-memory caching less effective, and a dependency upgrade could break several destinations at once. Going back is a migration like any other, so plan it in steps rather than as one rewrite.

Did Amazon Prime Video abandon microservices?

No. In 2023 one Prime Video team wrote that it moved one service, its audio and video monitoring tool, from distributed components orchestrated by AWS Step Functions into a single process, and that this reduced that service's infrastructure cost by over 90%. The same write-up said the choice between microservices and a monolith has to be made on a case-by-case basis. It is one service's result, not a company-wide policy.

Next articleOpenAI Evals Alternatives: Export by Oct 31, Gone Nov 30

Get in touch.

Whether you have questions or just want to explore what's possible, we're here to help.