C++ Backend Services
When a backend is infrastructure, no garbage collector and direct control of memory buy predictable tail latency — at a price paid in engineering time and safety.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
When is a backend service worth writing in C++, and what does that choice actually buy?
A component sits on the path of every request in the system — a proxy, a gateway, a cache, a matching engine — and its tail latency is the product rather than an implementation detail.
C++ is faster, so performance-sensitive services should be written in C++. Rewrite the slow service and the problem is solved.
The slow service was waiting on a database, so the language it waited in changed nothing. Most backend latency is I/O and downstream systems, not local computation (Why Is My API Slow?).
- The slow service was waiting on a database, so the language it waited in changed nothing. Most backend latency is I/O and downstream systems, not local computation (Why Is My API Slow?).
- The rewrite takes many times longer than expected, and the new service has a class of bug the old one could not have: use-after-free, buffer overflow, data race on shared memory.
- A memory-safety bug in a network-facing service is a security vulnerability, not just a crash (The Backend Security Checklist).
- Hiring and on-call get harder: fewer engineers can safely modify the service, and the ones who can become a bottleneck.
- Ecosystem work that was one dependency elsewhere — an OAuth client, a cloud SDK, a metrics exporter — becomes weeks of integration.
What is actually happening
- The real advantage is absence of a garbage collector. Managed runtimes pause, and pauses land in the tail. Where p99.9 is the specification, removing that variance matters more than raw throughput (Garbage Collection: Pause, Throughput, Footprint — Pick Two in Performance).
- Direct control over memory layout means data can be arranged for cache locality, which for hot, data-intensive paths can dominate everything else (Memory Moves in Lines, Not Variables in Computer Architecture).
- The dominant server architecture is thread-per-core with an event loop per thread: pin one thread to each core, give each its own epoll (or
io_uring) loop and its own connections, and share as little as possible so there is no cross-core contention. - Shared-nothing per core avoids locks on the hot path. Where state must be shared, the cost is cache-coherence traffic between cores, which is why the design goal is to avoid sharing rather than to lock efficiently (False Sharing: Independent Data, Shared Line in Concurrency).
- RAII ties resource lifetime to scope, which is how sockets, buffers and locks get released deterministically without a collector — and the mechanism that makes lifetime bugs a compile-time-adjacent discipline rather than a runtime guarantee.
- The concurrency hazards are real ones: data races are undefined behaviour rather than merely wrong answers, and the C++ memory model is something you must actually know (What a Memory Model Defines in Concurrency).
- Established building blocks exist and are worth using instead of writing your own: Boost.Asio for asynchronous I/O, gRPC's C++ implementation for RPC, Seastar for shared-nothing thread-per-core services. Nginx and Envoy are the reference examples of the architecture in production.
What you are actually buying
Framed honestly, this is a decision about variance and per-request cost, not about speed in general. If your service spends most of a request waiting on a database, the language of the waiting is irrelevant.
Where it pays: a component on the path of every request, where a garbage collector's pauses land in your p99.9, or where CPU per request multiplied by request rate is a fleet-sized bill.
What dominates a request in this specific component?
when The typical CRUD or business service.
cost A native rewrite buys essentially nothing; the wait is the same length in every language (Calling Something You Do Not Control).
when Proxying, parsing, compression, encryption, encoding, matching.
cost Real savings in fleet size, paid for in development time and a memory-safety risk you must actively manage.
when p99.9 in single-digit milliseconds; GC pauses are already visible in your histograms.
cost You inherit responsibility for allocator behaviour and page faults, which do not disappear.
when Kernel bypass, custom protocols, io_uring, device access.
cost A very small pool of engineers who can maintain it.
when Nobody on call can safely debug a segfault at 3am.
cost Choosing it anyway converts a performance problem into a staffing problem (Choosing a Runtime).
Thread-per-core, shared nothing
io_uring is the newer interface with a completion-based rather than readiness-based model; kqueue is the BSD and macOS equivalent; Windows IOCP is completion-based and structures the whole design differently.The architecture that makes native servers fast is not "threads are fast". It is the removal of coordination: each core runs its own event loop over its own set of connections, and the hot path shares nothing, so there is no lock and no cache line bouncing between cores.
The sketch below is the shape, using the POSIX and standard-library pieces directly. In production you would build on Boost.Asio or Seastar rather than hand-rolling the loop — the point here is to see that there is no magic in it.
1#include <sys/epoll.h>2#include <unistd.h>3#include <thread>4#include <vector>5#include <array>6 7// One loop per core. Each owns its own epoll instance and its own8// connections. Nothing on the hot path is shared, so nothing on the9// hot path needs a lock.10class Reactor {11public:12 Reactor() : epfd_(::epoll_create1(0)) {}13 ~Reactor() { if (epfd_ >= 0) ::close(epfd_); } // RAII: the fd is14 // owned, so it is closed15 16 Reactor(const Reactor&) = delete; // non-copyable: an fd17 Reactor& operator=(const Reactor&) = delete; // has one owner18 19 void add(int fd, uint32_t events) {20 epoll_event ev{};21 ev.events = events;22 ev.data.fd = fd;23 ::epoll_ctl(epfd_, EPOLL_CTL_ADD, fd, &ev);24 }25 26 void run() {27 std::array<epoll_event, 256> events{};28 for (;;) {29 int n = ::epoll_wait(epfd_, events.data(), events.size(), -1);30 for (int i = 0; i < n; ++i) {31 handle_ready(events[i].data.fd, events[i].events);32 // Anything slow here costs this ENTIRE core: every connection33 // owned by this loop waits. Same rule as any event loop --34 // the unit that stalls is just one core instead of the process.35 }36 }37 }38 39private:40 void handle_ready(int fd, uint32_t events);41 int epfd_;42};43 44int main() {45 const unsigned cores = std::thread::hardware_concurrency();46 std::vector<Reactor> reactors(cores);47 std::vector<std::thread> threads;48 threads.reserve(cores);49 50 for (unsigned i = 0; i < cores; ++i) {51 // Each thread runs one reactor. Connections are partitioned across52 // them (SO_REUSEPORT gives each thread its own accept queue), so53 // two cores never touch the same connection state.54 threads.emplace_back([&reactors, i] { reactors[i].run(); });55 }56 for (auto& t : threads) t.join();57}Two things to take away even if you never write this: the loop-blocking rule from Node applies per core here, and the reason this design is fast is that nothing is shared, not that the language is C++.
The honest comparison
Set against a managed runtime, the trade is legible. Everything in the left column is bought with everything in the right, and the exchange rate depends entirely on which component you are building.
// Garbage collector pauses land in the tail: // p50 unaffected, p99.9 shows the pauses // Per-request allocation you do not control // Memory overhead: object headers, heap slack // JIT warm-up after every deploy or restart // Less control over data layout and cache behaviour // For a typical business service, ALL of this is // invisible next to a 20ms database query.
// Memory-safety bugs that are security vulnerabilities // Data races as undefined behaviour, not wrong answers // Longer development and review cycles // A much smaller pool of engineers who can be on call // Ecosystem work: SDKs, auth, metrics, tracing // Build and dependency management complexity // For a proxy on the path of every request in the // company, ALL of this can be worth paying.
Neither column is a defect list. They are the two bills, and which one you would rather pay depends on whether the component's cost is dominated by waiting on other systems or by its own CPU and its own tail. Measure that first; the language question answers itself afterwards.
How to build it
Most important first.
- Choose it for the workload shape, not the reputation: a component on every request path, where tail latency or per-request CPU cost is the actual product (Choosing a Runtime).
- Keep the C++ surface as small as possible. A fast data-plane component with control-plane logic in a higher-level language is far easier to operate than a large C++ application.
- Use modern C++ discipline throughout: RAII everywhere, smart pointers over raw owning pointers,
std::string_viewand spans for non-owning views, containers over manual allocation. - Run sanitizers in CI — AddressSanitizer, UndefinedBehaviorSanitizer, ThreadSanitizer — and fuzz every parser that touches network input. For a network-facing C++ service these are not optional extras (A Test Strategy Chosen by What Each Layer Can Prove).
- Prefer thread-per-core with a loop per thread and no shared mutable state on the hot path; make cross-thread communication explicit through queues rather than shared structures.
- Bound everything explicitly — connections, buffer sizes, in-flight requests — because there is no runtime to absorb the mistake (Resource Limits).
- Instrument to the same standard as any other service. "It is C++" is not a reason to have worse observability, and it is often the reason a problem takes days (The Metrics a Backend Must Emit).
What can go wrong
- Use-after-free and buffer overflow: crashes at best, remote code execution at worst, and both are reachable from the network.
- Data races producing undefined behaviour — results that are not merely wrong but that let the compiler assume the race cannot happen.
- Memory fragmentation over long uptimes, so a process that is fine for a week degrades in the second — a slow failure with no obvious cause.
- Blocking a loop thread with a synchronous call, which on a thread-per-core design costs an entire core's worth of capacity (Blocking the Event Loop).
- Deployment and dependency management complexity: a build that works on one base image and not another, and a much longer path from bug to deployed fix.
- The mitigation failing: sanitizers run only in a test suite that does not exercise the concurrent paths where the real races live.
- Data races in C++ are undefined behaviour, not just nondeterministic results: the compiler is permitted to optimise on the assumption that they do not occur, so symptoms can appear far from the racing code (Backend Races).
- Lock-free structures are easy to get subtly wrong — the ABA problem and memory-ordering mistakes are the classic examples — and their failures are rare and load-dependent.
- Object lifetime across threads is a race in its own right: a pointer valid when it was passed and freed before it was used produces a use-after-free that only appears under concurrency.
- Memory safety is the security story. A significant share of critical vulnerabilities in network-facing C and C++ code are memory-safety issues, which is why guidance increasingly favours memory-safe languages for new network-facing services.
- Parsers are the highest-risk component and must be fuzzed continuously. Anything that reads attacker-controlled bytes is where the exploitable bugs are (Parsing HTTP).
- Integer overflow in length arithmetic is a classic path to a buffer overflow, and it is silent by default for unsigned types.
- Dependency and supply-chain risk is harder to manage without a single dominant package manager; know what is vendored and how it gets patched (Dependency Security).
- If the choice is available, Rust offers most of the same performance properties with memory safety enforced by the compiler. That is a serious consideration for new network-facing services, and not a reason to rewrite a working C++ service.
- "C++ is faster, so the service will be faster." Only for the part that was CPU-bound in your process. A service that waits on Postgres waits exactly as long in any language.
- "Native means no latency spikes." No GC pauses. Allocator behaviour, page faults, lock contention and kernel scheduling all still produce spikes.
- "Modern C++ is memory safe." Modern C++ makes safety easier to achieve and does not enforce it. The guarantee is a discipline, not a compiler property.
- "It is only for high-frequency trading." Proxies, API gateways, caches, databases, media servers and storage engines are all ordinary infrastructure written this way.
- "We should rewrite our API in C++." Almost never. Rewrite the component on every request path whose CPU or tail latency you have measured and cannot fix otherwise.
Operating it
- Per-core metrics, not per-process: on a thread-per-core design, one saturated core with seven idle ones is a load-distribution bug that an averaged CPU metric hides.
- Latency histograms rather than averages. The entire reason for choosing this runtime is the tail, so measure the tail (Percentiles: Which One, and How Many Users Is That?).
- Continuous profiling with
perfand flame graphs. Native services have excellent profiling tools, and using them is part of the value of being here (Reading a Flame Graph). - Allocation and fragmentation metrics from the allocator itself, since slow degradation over long uptimes usually shows up there first.
- Crash handling that produces a usable core dump and a symbolised stack. Without it, a segfault is a restart and no information.
- At high request rates the per-request CPU cost becomes the fleet size, which is where a native service can be worth several times its development cost in machines saved.
- Thread-per-core scales linearly with cores until a shared resource appears; the discipline is keeping the hot path shared-nothing so that stays true (NUMA: Not All Memory Is Equally Far in Computer Architecture matters at large core counts).
- It does not change the fact that most backends are I/O-bound. For a typical CRUD service at any scale, the database and the network still decide the latency (Backend Runtime Models).
- Predictable tail latency and low per-request cost, paid for in development speed, a much smaller pool of engineers who can safely change the code, and a category of bug that is also a vulnerability.
- Manual memory management gives control and gives you the responsibility that a collector was carrying.
- The ecosystem gap is real: things that are one line in another language are a project here.
- Rust is worth evaluating for new work: comparable performance characteristics with compiler-enforced memory safety, at the cost of its own learning curve and a smaller (though rapidly growing) backend ecosystem.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- LANGUAGE-SPECIFICC++ specifically, and its properties come from having no garbage collector and no enforced memory safety. Rust shares the first and adds the second, so it gets similar latency characteristics with a very different safety and learning profile; Go has a collector with short pauses and is a reasonable middle ground where the tail requirement is real but not extreme.
- SCALE-SPECIFICThe economics only work above a threshold. At modest request rates the machine savings are smaller than one engineer's time, so the choice is almost always wrong; on a component carrying every request in a large system it can pay for itself many times over.
- RUNTIME-SPECIFICThread-per-core with a loop per core is the dominant architecture here, and it degrades differently from a shared thread pool: one blocked loop thread costs exactly one core's worth of connections rather than being absorbed by a work-stealing scheduler.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.