Skip to content
Go back

Monolith vs Microservices, Measured

By KingPin 15 min read
Monolith vs Microservices, Measured
Contents

The short version: the monolith won, and Postgres had a say

We built one small shop app twice in Go. The first build is a monolith. The second is a gateway plus three gRPC services (catalog, cart, orders). Both share the same domain code and SQL, both expose the same HTTP/JSON API, and both talk to the same Postgres 18.6. Then we pinned each part to its own CPU cores and measured.

In this matrix, on this host, microservices did not win a single throughput or capacity verdict. The monolith led every closed-loop throughput test, by 11% on browsing up to 87% on checkout. The microservices best results were ties: p99 latency at equal load, and load capacity in two comparisons. The monolith also used far less memory and a fifth of the image size.

The result carries a caveat that the numbers force on us. In mix-max and browse-max, Postgres ran at 1.8 to 2.0 of its 2 cores in both designs. In cart-max and checkout-max the microservices app used about 2.8 of its 3 cores while Postgres used about 1.1 to 1.2. We say so in the box at the top and again below.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/…

This is a SumGuy’s Lab post: numbers we measured ourselves, with the test kit and raw results published so you can check our work.

The scorecard

TestMonolithMicroservicesVerdict
browse-max6,318 req/s5,683 req/smonolith by 11%
cart-max14,689 req/s9,204 req/smonolith by 60%
checkout-max9,936 req/s5,318 req/smonolith by 87%
mix-max6,998 req/s5,824 req/smonolith by 20%
mix-fixed p999.75 ms5.03 mstie
mix-ramp capacity4,556 req/s4,555 req/stie
Memory (cgroup), idle / peak3.5 / 12.1 MiB15.8 / 37.2 MiBmonolith
Image size20.8 MiB106.3 MiB (four images, summed)monolith

All throughput numbers count 2xx responses only. Every measured cell except image size is a median of 3 runs. The non-2xx column read 0 in every throughput and latency test.

What we built and how we tested

The app is a small shop. Browsing lists products, the cart adds and reads items, and checkout turns a cart into an order. In the microservices build, a gateway takes the HTTP request and calls catalog, cart, and orders over gRPC. A cart read makes two internal calls. A checkout makes four.

We framed this as practical defaults. Each design is built the way a team would ship it, so the work is not identical. Three differences come out of that, and we count them as part of the result:

  1. The checkout transaction. The monolith reads the cart, reads prices, inserts the order, and clears the cart in one transaction. The microservices build commits the order first, then calls cart to clear it. That second step is not atomic. If it fails, the order stays committed.
  2. The pool split. The monolith has one pgx pool with a maximum of 30 connections. The microservices build has three pools of 10, one per service. The total is the same, but a browse-heavy load can use all 30 in the monolith and only the catalog pool’s 10 in the other.
  3. GOMAXPROCS under CPU quotas. Go sets GOMAXPROCS from the container’s CPU limit. For the quota tests we report the GOMAXPROCS value each container ran with. See the quota section.

The rig:

Throughput at fixed concurrency

Each row below is a closed loop: 16 users, no think time, as fast as the app answers.

ScenarioMonolith req/sMicroservices req/sVerdict
browse-max6,318 [6,235-6,545]5,683 [5,651-5,730]monolith by 11%
cart-max14,689 [14,682-14,986]9,204 [9,161-9,206]monolith by 60%
checkout-max9,936 [9,799-9,964]5,318 [5,242-5,331]monolith by 87%
mix-max6,998 [6,941-7,060]5,824 [5,749-5,866]monolith by 20%

Brackets show the minimum and maximum of the 3 runs. For checkout-max, that is 4,968 completed checkouts per second on the monolith against 2,659 on microservices.

The gap tracks the number of internal calls. Browsing makes one gRPC call and the gap is 11%. Checkout makes four and the gap is 87%. The CPU cost shows the same thing. App CPU-seconds per 1,000 requests, mix-max: 0.14 for the monolith, 0.42 for microservices. On checkout-max it is 0.17 against 0.52. The extra CPU sits in the gateway and service processes, which do the gRPC work. The browse gap is also held down by the shared Postgres ceiling, with both designs near 2.0 of its 2 cores, so the 11% likely understates the app-layer difference.

Postgres CPU per 1,000 requests stayed close between the designs: 0.28 against 0.32 on mix-max. The app layer accounts for most of the difference.

Latency at equal load

At a fixed arrival rate, both designs delivered the same throughput (3,597 and 3,598 req/s), so latency compares fairly.

Designp50 msp95 msp99 ms
Monolith0.381.769.75 [8.73-10.26]
Microservices0.722.445.03 [4.27-16.93]

The microservices build is slower at p50 and p95. At p99 the medians favor microservices, but the verdict is a tie, because the microservices run-to-run range reaches 16.93 ms and covers the monolith’s median. At least one of the three runs had a bad tail. We do not know why, and three runs cannot tell us whether it repeats. The monolith’s p99 sat above the microservices p99 at low load in other tests too: matrix ramp steps 1 to 3 gave 5.5, 9.4, and 14.6 ms against 3.1, 2.5, and 5.2 ms, the B=3 ramp gave mono 4.4, 6.1, and 13.2 ms against micro-tuned 3.7, 3.3, and 4.9 ms, and mix-fixed gave 9.75 against 5.03 ms. We report that as an observation and did not trace it to a cause.

Load capacity, and what a tie means here

The ramp test raises the offered rate in 10 steps of 20 seconds, from 1,371 to 10,285 iterations per second. Capacity is the highest step that keeps p99 under 50 ms, non-2xx under 0.1%, and dropped arrivals under 0.1%.

ConfigCapacity, 2xx req/sVerdict
Monolith4,556 [4,553-4,558]
Microservices4,555 [3,518-4,558]tie

The steps are about 990 iterations per second apart (1,371, 2,361, 3,352, 4,342, 5,333, and so on). Capacity therefore has one-step resolution. Every config that passed step 4 and failed step 5 lands near 4,556 req/s. A tie in this test means “within one step”, not “identical”. The microservices lower bound of 3,518 is a run that fell back one step.

Look at the steps and the gap shows. At step 4 (4,342 offered), the monolith had p99 of 7.2 ms and microservices had 24.1 ms. The medians passed (one microservices run did not, at 142.5 ms). At step 5 (5,333 offered), p99 was 131.8 ms against 224.7 ms. Both failed. The monolith has more headroom inside the step, but this ramp cannot resolve it.

Overload looks different

At step 10 (10,285 offered), the monolith returned 0.00% non-2xx. It dropped 38.15% of arrivals and kept answering. The microservices median was 82.86% non-2xx with 45.41% dropped, but that hides a split: two of three runs returned 83 to 85% non-2xx and the third returned none. In the CPU-quota runs below, the medians show a similar pattern: 25.15% non-2xx for micro-even and 67.29% for micro-tuned at step 10, against 0.00% for the monolith.

We report this as an observation. We did not trace it to a cause. Every gRPC client, the gateway included, uses a 2 s deadline, and the gateway maps a timeout to a 502. That is a candidate, and we did not test it.

Memory and image size

DesignIdle MiB (cgroup)Peak MiB (cgroup, mix-max)Images
Monolith3.512.120.8 MiB
Microservices15.837.2106.3 MiB (four images, summed; the base layer is shared on disk)

Four Go processes each carry their own runtime, plus the gRPC stack. The 90 s sustained run agreed with the short runs: 6,906 req/s on the monolith against 5,764 on microservices, p99 of 9.7 ms against 9.2 ms, and no non-2xx on either. At 12.4 MiB and 37.1 MiB, peak memory did not climb over the longer run.

Where the pool split shows up

Each binary prints its pgx pool statistics at shutdown. These counts cover the warm-up plus the measured run, not the measured run alone.

ScenarioDesignPool (max)AcquiresEmpty acquiresAcquire time
mix-maxmonolithshop (30)300,18016301 ms
mix-maxmicrocatalog (10)205,65667,76031,675 ms
mix-maxmicrocart (10)69,74312149 ms
mix-maxmicroorders (10)11,60111153 ms
checkout-maxmicrocatalog (10)135,68019,2758,311 ms

An empty acquire means the pool had no free connection and the caller waited. In mix-max, the catalog pool had 67,760 empty acquires out of 205,656. The monolith, with one pool of 30, had 16 out of 300,180. Browse traffic hits catalog hard, and a pool of 10 is the smaller share of the same 30. The split is a default a team would plausibly ship. It may cost throughput. We did not rerun microservices with a larger catalog pool, so we cannot say whether it costs anything or how much of the 20% mix-max gap it would close. Postgres was already near 1.9 of its 2 cores in that test, so a bigger pool could move the queue into the database and change nothing.

CPU quotas: the split matters a lot

The last test asks the question people actually argue about: given the same CPU budget, does per-service quota allocation beat a monolith? We gave the app a total budget B of 2 cores, then 3 cores. The monolith got a quota of B. The microservices build ran two ways: micro-even, where each of the four containers gets B/4, and micro-tuned, where quotas follow each service’s measured share of app CPU. At B=2 the tuned quotas were 0.8 (gateway), 0.7 (catalog), 0.3 (cart), and 0.2 (orders). At B=3 they were 1.2, 1.0, 0.5, and 0.3.

Budget B = 2, mix-max

Config2xx req/sp99 msThrottled s (summed over containers)Verdict vs monolith
mono7,188 [7,112-7,562]9.40.0
micro-even1,224 [1,155-1,236]74.526.3mono by 487%
micro-tuned4,675 [3,800-4,987]31.617.7mono by 54%

Budget B = 3, mix-max

Config2xx req/sp99 msThrottled s (summed over containers)Verdict vs monolith
mono7,120 [6,943-7,457]9.40.0
micro-even3,151 [2,852-4,365]41.812.3mono by 126%
micro-tuned5,924 [5,879-5,965]9.10.1mono by 20%

The monolith’s throughput barely moved between 2 and 3 cores, which fits Postgres sitting near its ceiling in mix-max. The microservices numbers moved a lot. Every microservice container ran with GOMAXPROCS=2 at both budgets, with quotas of 0.2 to 1.2 cores. Throttled time is cgroup throttled_usec summed over the four containers, so it can exceed the 30 s test length. Micro-even at B=2 logged 26.3 s of throttled time and delivered 1,224 req/s.

The arithmetic that fits is a quota below demand. Unconstrained microservices used 0.42 CPU-seconds per 1,000 requests at 5,824 req/s, about 2.4 cores, so a 2-core budget cannot carry that rate. Micro-even also gives the gateway 0.5 cores, while it takes about 40% of app CPU. App CPU per 1,000 requests rose from 0.42 to 1.02 under micro-even at B=2. We did not trace that rise to a cause. GOMAXPROCS above the quota did not throttle by itself: at B=3, micro-tuned also ran GOMAXPROCS=2 everywhere, with quotas of 0.3 and 0.5 on orders and cart, and logged only 0.1 s of throttled time. No run overrode GOMAXPROCS, so we cannot say what it would change.

Tuning the split lifted B=2 from 1,224 to 4,675 req/s and B=3 from 3,151 to 5,924 req/s. At B=3, micro-tuned nearly closed the throttling gap (0.1 s) and matched the monolith’s p99 (9.1 ms against 9.4 ms). The monolith still led on throughput, by 20%.

Capacity at B=3 told a matching story:

ConfigCapacity, 2xx req/sVerdict
mono4,556 [4,554-4,564]
micro-even3,521 [3,518-3,523]mono by 29%
micro-tuned4,561 [4,559-4,563]tie

Micro-tuned reached the same capacity step as the monolith. That is the closest microservices came to winning anything, and it needed a quota split derived from measured per-service CPU shares. A team that divides a budget evenly gets the 3,521 row.

What this does and does not say

  1. Postgres capped some loads. In mix-max at 16 VUs (the lowest count swept, already within 5% of peak), Postgres used 1.94 cores for the monolith and 1.83 for microservices, out of 2. Browse-max sat near 2.0 in both. In cart-max and checkout-max the microservices app used about 2.8 of its 3 cores while Postgres used about 1.1 to 1.2. The throughput gaps describe this CPU split, and a bigger database allocation could move the browse and mix gaps.
  2. Microservices won nothing here. On this host and in this matrix, no throughput or capacity verdict went to microservices. Their best results were three ties: mix-fixed p99, matrix ramp capacity, and micro-tuned ramp capacity at B=3.
  3. No network between services. Gateway and services share one host, so gRPC calls never cross a real network. Deployments that separate them add network latency that this test did not measure.
  4. We did not measure what microservices are usually bought for. Independent deploys, fault isolation, team boundaries, and per-service scaling were out of scope. This post measures the cost side of the ledger only.
  5. Simplifications. No stock reservation (it needs a saga in the microservices build), no auth, no cache. Every request reaches Postgres.

Our take

If you run a shop-sized service on a few cores, start with the monolith. It was faster in every closed-loop test here, it used about a third of the peak memory, and its checkout is one transaction. If you split anyway, divide the CPU budget by each service’s measured load. The gap between micro-even and micro-tuned at B=2 was bigger than the gap between monolith and tuned microservices at B=3. Your 2 AM self will prefer one process to four.

Common Questions

Is a monolith faster than microservices?

Yes, in this test. On one host with the same CPU budget, the monolith led browse by 11%, cart by 60%, checkout by 87%, and the mixed load by 20%. Each microservices request pays for gRPC calls between services. Your numbers depend on workload, database size, and network.

Why did microservices lose in a quota test?

A quota below the service’s measured need explains the loss best. An even split at a 2-core budget gave the gateway 0.5 cores, logged 26.3 s of throttled time across four containers, and managed 1,224 req/s. A split tuned to each service’s CPU share reached 4,675 req/s.

Do microservices ever beat a monolith on throughput?

Not here. Microservices tied the monolith on equal-load p99 and on two load-capacity comparisons, and won no throughput verdict. This matrix covered one host and one Postgres instance. Per-service scaling across separate machines was out of scope, so it can still matter for you.

How much more memory do microservices use?

In this app, about three times the peak. The monolith peaked at 12.1 MiB and idled at 3.5 MiB. The four microservices containers peaked at 37.2 MiB and idled at 15.8 MiB. Images came to 106.3 MiB against 20.8 MiB. Four Go runtimes each carry their own overhead.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Docker's Embedded DNS: Inside 127.0.0.11
Next Post
Lynis: Reading a Hardening Audit Report

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts