Design

6. Runtimes and frameworks

Languages, runtime variants, and frameworks as tradeoffs, not tiers.

A Stack is a language, a runtime variant and a framework, and every App Server and Worker runs one. It is a cost profile that suits some designs and fails in others. This page is the main application of pillar 2: tradeoffs, not tiers.

"PHP is slow" is true and incomplete. PHP-FPM is slow per request, but it cannot leak memory between requests and it deploys by copying files. Node.js is fast on I/O, but one CPU-heavy request stalls the whole process. The Simulation models the reasons, so the player learns where each choice wins.

The six axes#

Every Stack choice moves along these axes. The Tradeoff card shows all six as a radar chart, plus the simulated effect on the player's own design (Interface).

AxisMeasuresDriven by
ThroughputRequests per second per dollar of instanceCPU speed, concurrency model, memory per unit of concurrency
Tailp99 latency under loadGC pauses, event-loop blocking, warmup, scheduling
MemoryRAM per process and per in-flight requestRuntime base, framework, concurrency model
VelocityDev Hours per featureLanguage, framework, ecosystem (Team)
ResilienceHow much a crash, leak, or bad request breaksBlast radius, isolation, memory leaks
OperabilityDeploy time, build time, startup, hiringToolchain, runtime startup, talent pool

The capacity model#

An App Server's capacity is the minimum of four limits. The tightest one is the bottleneck, and the card always names it (Observability).

capacity=min⁡(cpu limit, concurrency limit, memory limit, network limit)\text{capacity} = \operatorname{min}\left(\text{cpu limit},\ \text{concurrency limit},\ \text{memory limit},\ \text{network limit}\right)
LimitFormulaWhat it means
cpu_limitusable_cores / cpu_time_per_requestHow much CPU work the box can do.
concurrency_limitslots / residence_timeHow many requests can be in flight, divided by how long each one stays (Little's law).
memory_limitRAM used against the RAM the instance has.Past it the box swaps and CPU efficiency falls. A thread-per-request runtime also cannot start another thread.
network_limitinstance_bandwidth / bytes_per_requestMatters for media and upload.

Where:

cpu time per request=∑kindsshare(kind)×ref cpu ms(kind)×framework overheadspeedresidence time=cpu time per request+∑synchronous callsdownstream latencyusable cores=min⁡(vCPU, processes×cores per process)\begin{aligned} \text{cpu time per request} &= \sum_{\text{kinds}} \text{share} \left(\text{kind}\right) \times \text{ref cpu ms} \left(\text{kind}\right) \times \frac{\text{framework overhead}}{\text{speed}} \\ \text{residence time} &= \text{cpu time per request} + \sum_{\text{synchronous calls}} \text{downstream latency} \\ \text{usable cores} &= \operatorname{min}\left(\text{vCPU},\ \text{processes} \times \text{cores per process}\right) \end{aligned}

The concurrency model decides what slots and cores_per_process are. That is where runtimes differ most.

Concurrency models#

ModelExamplesSlotsCores per processWaiting on I/O holds…
Process per requestPHP-FPM, Gunicorn sync, UnicornWorker processes: the pool size the player sets1A whole worker process
Thread poolTomcat with Spring MVC, PumaThreads: processes × threads per processAll cores, or 1 with a GILA whole thread
Event loopNode.js, Python asyncio, Swoole, Spring WebFluxEffectively unlimited (memory per in-flight request is small)1 per processA few KB of memory
Green threads (M:N)Go goroutines, Java virtual threads, Erlang processesEffectively unlimitedAll coresA few KB of memory
Async on a thread poolRust Tokio, .NET asyncEffectively unlimitedAll coresA few KB of memory

Process-per-request and thread-pool runtimes pay for a slow database in capacity, because each waiting request holds a worker. Event loops and green threads pay in latency only, and their capacity stays limited by CPU.

Take a 4-vCPU PHP-FPM box with OPcache and 20 workers. It serves read at 4 ms of CPU (1.5 ref ms ÷ 0.375) and waits 16 ms on the database. Residence is 20 ms, so the concurrency limit is 20 / 0.020 = 1,000 rps. If the database slows to 96 ms per query, residence is 100 ms and the limit falls to 200 rps, while the CPU sits at 20%. The player sees a full box with idle CPU. The fixes are more workers (more RAM), a faster database, or a runtime that does not hold a worker while it waits.

Event loops and blocking#

A CPU-heavy request blocks every other request on its process until it finishes, because an event loop runs one piece of JavaScript at a time per process. The Simulation models this as a tail event:

loop block ms=(∑kindsshare(kind)×cpu ms(kind)×cpu ms(kind))/(∑kindsshare(kind)×cpu ms(kind))added p99=loop block ms×min⁡(1, ρ×2)\begin{aligned} \text{loop block ms} &= \left(\sum_{\text{kinds}} \text{share} \left(\text{kind}\right) \times \text{cpu ms} \left(\text{kind}\right) \times \text{cpu ms} \left(\text{kind}\right)\right) / \left(\sum_{\text{kinds}} \text{share} \left(\text{kind}\right) \times \text{cpu ms} \left(\text{kind}\right)\right) \\ \text{added p99} &= \text{loop block ms} \times \operatorname{min}\left(1{,}\ \rho \times 2\right) \end{aligned}

The squared term means rare heavy requests dominate. With 2% login at 80 ms of bcrypt and 98% read at 1.5 ms, the p99 of every read on that process rises by tens of milliseconds. This is the real "don't block the event loop" rule (Reality check).

The fixes, each with its own cost:

FixEffectCost
Async cryptoResearch "libuv thread pool" takes the login hash off the event loop, so logins stop stalling the requests behind them.Dev Hours for the Research, and 8 MB of RAM a process.
Worker threadsMoves CPU-heavy kinds to a pool of worker threads.Dev Hours to adopt. Each worker thread is a separate V8 isolate, about 10 MB.
Separate serviceRoutes login or upload to a separate App Server group.Another service: more Nodes, a load balancer route, a team boundary.
Auth ProviderTakes login off your servers.Per-user price (Components).
Different runtimeGo, Java, and Elixir do not stall other requests on one heavy request.A migration (Team).

The libuv pool is four threads a process at about 2 MB of stack each, and a login waits for a thread when more hashes arrive than the pool can run. worker-threads adds a pool of 4 threads, 40 MB, and takes upload, report and job off the loop.

Preemptive scheduling on the BEAM#

Erlang and Elixir processes are preempted after a budget of reductions, so a heavy request cannot starve others and the p99 stays flat under mixed load. The cost is raw CPU speed, which is lower than the JVM or Go. Long-running native functions (NIFs) are the exception: they are not preempted, and a crash in one takes down the whole VM.

Language profiles#

Speeds are stylized and relative to Go = 1.0. They follow the order seen in public benchmarks such as TechEmpower and the Computer Language Benchmarks Game, compressed so that no language is useless (Reality check).

LanguageDefault variantSpeedModel (default)RAM per processVelocityBuild timeStartupBlast radius
PHPApache mod_php0.15Process per request60 MB per worker1.200.5 minInstantRequest
RubyPuma0.25Thread pool with GVL180 MB (250 MB with Rails)1.101 min10 sRequest
PythonGunicorn sync0.20Process per request120 MB per worker1.151 min5 sRequest
Node.jsSingle process0.70Event loop80 MB1.153 min1 sProcess
JavaTomcat, 200 threads1.10 warmThread pool500 MB0.906 min20 s + warmupRequest
C#Kestrel1.10Async thread pool150 MB1.004 min3 s + warmupRequest
Gonet/http1.00Goroutines30 MB0.951.5 minInstantRequest
ElixirPhoenix on the BEAM0.45BEAM processes100 MB1.002 min3 sRequest
RustTokio1.40Async thread pool10 MB0.6012 minInstantTask

Blast radius is what a poison request (a request that triggers a crash) takes down. "Request" means only that request fails. "Process" means every in-flight request on that process fails and the process restarts. Node.js exits on an unhandled exception or rejection by default. Haskell's Warp isolates exceptions per request, so its radius is a request.

A crash caused by the code (a bad deploy, a migration's bugs, a runtime incident) fails a share of the requests a runtime holds:

  • Request radius: 1×.

  • Process radius (Node.js and Zig): 1.5×, capped at all of them.

  • Task radius (Rust): 0.75×.

Zig and Haskell profiles#

Series A research also unlocks two configured servers. Their numeric values are game-balance choices, not measured language rankings. Research sources and model limits describe the assumptions.

  • Zig threaded server: native code, explicit allocators, no GC, and 64 OS-thread slots across cores. Waiting requests occupy slots. Manual lifetimes and persistent state can still cause leaks. The profile has 24 MB base memory, 1.3 CPU speed, 0.7 feature velocity, and specialist hiring.

  • Haskell (GHC + Warp): lightweight threads with multicore GHC enabled. Socket waits release OS threads, but the shared heap has GC pauses. The profile has 80 MB base memory, 0.85 CPU speed, and 1.15 feature velocity, paid for through slower builds, specialist hiring, and a 1.4 salary factor. WAI is the application interface, not an extra framework unlock.

Hiring and salaries#

LanguageHiring poolTime to hireSalary
JavaScript and TypeScriptVery large1.0×1.00×
PythonVery large1.0×1.05×
JavaLarge1.1×1.05×
C#Large1.1×1.00×
PHPLarge1.0×0.85×
GoMedium1.4×1.15×
RubyMedium1.5×1.10×
RustSmall2.0×1.25×
ZigSmall3.0×1.25×
HaskellSmall2.5×1.40×
ElixirSmall2.5×1.20×

Pool sizes follow the order of the Stack Overflow Developer Survey usage results. A company that writes everything in Elixir hires slowly. A company that writes everything in PHP hires quickly and cheaply (Team).

Runtime behaviors#

These behaviors are what make runtimes feel different during play.

BehaviorWho has itGame effect
JIT warmupJava (strong), C# (medium), Node.js (light), PHP with OPcache (cache fill)A new or restarted process runs at a fraction of its speed and ramps up. Java starts at 30% and reaches 100% after 30 s. Hurts autoscaling and deploys under load.
Interpreter warmupPuma (90%), Gunicorn (95%)A restarted process runs at a fraction of its speed and reaches full speed after 15 s. Hurts autoscaling and deploys under load.
GC pausesJava (Tomcat), C# (Kestrel), Go, Haskell (Warp)Above 70% heap use, some requests pause. Adds to the p99, not the p50.
GC pause sizeJava, GoJava G1: 30 ms. Parallel GC: 150 ms with +5% throughput. ZGC: 1 ms with +10% CPU. Go: under 1 ms, and up to 25% of CPU goes to GC under heap pressure.
No GC pauseNode.js, Ruby, PythonThese runtimes have no GC pause.
No GCRust, Zig, PHP (request-scoped memory)No pause tail. PHP frees all memory at the end of every request.
Per-process GCElixirNo global pause. Each BEAM process collects alone.
Memory leaksPersistent runtimes: Node.js, Ruby, Python, Java, C#, Go, Elixir, Rust, Zig, HaskellRSS grows with uptime. When the process reaches its RAM, it is killed (oom) and restarts cold. Fixes: restart the server, or Dev Hours to fix the leak.
Recycle workersAny server, by its switchRestarts each process now and then (pm.max_requests, a Puma worker killer), so the leak never builds. The server loses 1 − (startup + warmup lost) / 1,800 s of its speed to each cold start.
Shared nothingPHP-FPM classicEach request starts clean. No leaks, no state bleeding between requests. This is PHP's real strength.
Fault isolationElixir (supervisors), PHP (per request)A crash affects one request. Elixir restarts the crashed process in microseconds.
Hot code reloadElixir (release upgrades), PHP (file swap)Deploys with zero restarts. A deploy that ships Features restarts every App Server cold except a PHP or Elixir one, which reloads the code in place. Elixir hot upgrades cost 4 Dev Hours per release.
Cold startJava (worst), then Ruby, Python, C#, Node.js, then Go and Rust (best)Matters for Serverless Functions and autoscaling (Components).

Variants by language#

A variant is a runtime choice inside a language. Research unlocks it, and adopting it on a service costs Dev Hours (Team). No variant is strictly better than its siblings.

PHP#

VariantModelSpeedProfile
Apache mod_php (start)Process per request0.15Every Apache process embeds PHP, including the processes that serve static files, so memory runs out early. Simple to deploy.
PHP-FPM with NGINXProcess pool0.15NGINX serves static files. PHP workers handle only PHP. Pool modes below.
+ OPcache×2.5Skips compiling scripts on every request. A restarted box fills the cache again.
+ JIT (PHP 8)×1.05 on web kinds, ×1.6 on CPU-heavy kindsHelps upload and report, barely helps page and read. A deliberate lesson: the JIT is not a general speedup for web work. Research php-jit, over every PHP runtime.
Worker mode (FrankenPHP, RoadRunner, Laravel Octane)Persistent workers×1.0, framework overhead removedThe app boots once, not on every request. Framework-heavy apps gain 2× to 4×. Loses shared nothing: memory leaks and state bleed between requests become possible.
SwooleEvent loop with coroutines×1.0, overhead removedAsync I/O like Node.js. Can hold rt connections. Blocking extensions stall the loop. 60 MB and 20 KB per connection.

With validate_timestamps=0, a deploy has to reset the OPcache, or the old code keeps running. The Stale OPcache incident runs the old code against the new schema and fails 30% of a PHP server's requests. A CI/CD Pipeline resets the cache on every deploy, and a restart clears it by hand.

Worker mode is the PHP worker mode runtime. Laravel keeps 1.1× of its overhead per request in it, and every other framework keeps none.

PHP-FPM pool modes (a Tradeoff card):

pm modeBehaviorCost
staticA fixed number of workers, always running.Uses its full RAM all the time. Fastest.
dynamicKeeps the busy workers plus 2 spares, up to the pool size.2% of requests wait 20 ms for a fork during a burst.
ondemandKeeps the busy workers, up to the pool size.10% of requests wait 50 ms for a fork. Lowest idle RAM.

pm.max_children is the pool size. Setting it above what RAM allows causes swapping, then oom. Setting it too low leaves CPU idle while requests queue. The right value is RAM divided by the memory of one worker, which is the real sizing rule (Reality check).

Ruby#

VariantModelSpeedProfile
UnicornProcess per request0.25Simple and robust. Memory-heavy: one Rails process per slot. lang-ruby unlocks it with Puma, and it refuses rt connections.
Puma (start)Processes × 5 threads, with a GVL0.25Threads overlap I/O waits. CPU work runs on one core per process, so Puma runs one process per core.
+ YJIT×1.3Faster CPU work, +20% memory. Research yjit, over every Ruby runtime.
FalconFibers (event loop)0.25Many idle connections per process. Gems that block stall the loop. 100 KB per connection.

Python#

VariantModelSpeedProfile
Gunicorn sync (start)Process per request0.20One request per worker. Like PHP-FPM, with more memory per worker.
Gunicorn with geventGreen threads, with a GIL0.20Many I/O waits per process. Monkey patching breaks some libraries. 30 KB per connection.
Uvicorn ASGIEvent loop, with a GIL0.20Async like Node.js, but slower CPU. Blocking calls stall the loop. The Uvicorn ASGI runtime, with 30 KB per connection.
Free-threaded CPythonThreads without a GIL×0.9Real parallel threads on all cores. Single-thread speed is lower, and some C extensions are not ready, which costs Dev Hours. 8 threads and 100 KB per connection.
PyPyProcess per request, with a JIT×3Much faster pure-Python CPU work. Poor support for C extensions such as NumPy and PyTorch, so it cannot use the ML ecosystem bonus. 200 MB per worker.

Adopting gevent carries a small chance of a "Library incompatibility" incident, which fails 15% of a gevent server's requests. Automated tests find the library first.

ML ecosystem. A Python service can run small models in process (embeddings, reranking) without a GPU Node. So every Python App Server except PyPy earns +20% ai value when it links into a live Vector Index, and FastAPI adds 15% on top. Every other language must call a GPU Node or an LLM API. A Python server that answers ai with no index to retrieve from earns nothing extra.

Node.js#

VariantModelSpeedProfile
Single process (start)Event loop0.70Uses one core. Resizing the instance adds no CPU throughput. A deliberate lesson.
Cluster mode (PM2)One event loop per core0.70Uses all cores. Memory × processes. In-process caches are not shared between processes.
+ Worker threadsOffloads CPU-heavy kinds. See event loops and blocking. Research worker-threads.
BunEvent loop0.85Faster startup and throughput in benchmarks. Less mature. 60 MB.
DenoEvent loop with permissions0.70Permissions block most supply-chain attacks (Incidents). Fewer compatible packages: −10% velocity.

The Runtime compatibility incident fails 20% of a Bun server's requests, and automated tests find the package first.

Java#

VariantModelSpeedProfile
Tomcat thread pool (start)200 platform threads1.10 warmEach thread holds about 1 MB of stack. Above 200 waiting requests, new requests queue even with idle CPU.
Virtual threadsGreen threads1.10 warmWaiting does not hold a platform thread. From JDK 24, synchronized does not pin. Native (JNI) calls pin the carrier thread. 10 KB per rt connection.
Spring WebFluxEvent loop1.10 warmVery high concurrency. Hard to write and debug: −20% velocity. 100 MB.
GraalVM native imageThread pool0.80Starts in under 100 ms with no warmup and −60% memory. Lower peak speed. Builds take three times as long (Quarkus halves that). Great for Serverless Functions.
GC optionp99 pauseThroughputMemory
G1 (start)30 ms1.00×1.00×
Parallel150 ms1.05×0.95×
ZGC1 ms0.90×1.15×

Mature tooling. JVM services earn +25% Insight from sampled traffic, because profilers such as Java Flight Recorder expose so much (Observability). Samples from a Java App Server earn the 25% again under the same square root, as a Service Mesh's 20% does.

C##

VariantModelSpeedProfile
Kestrel (start)Async thread pool1.10Fast, async by default. Tiered JIT warmup is shorter than the JVM's.
Native AOTAsync thread pool0.90Fast startup, smaller memory. Reflection-heavy libraries need Dev Hours. 100 MB.

Go#

VariantModelSpeedProfile
net/http (start)Goroutines1.00A goroutine starts with a 2 KB stack. Static binary, instant startup. net/http recovers panics in handlers, but a panic in a background goroutine crashes the process.
fasthttpGoroutines1.25Faster, with a non-standard API. No HTTP/2. Some middleware does not work: −10% velocity.
GOGC settingMemoryGC CPUp99 effect
500.7×HighSmall
100 (start)1.0×MediumSmall
200 + GOMEMLIMIT1.5×LowRare large pause when near the limit

Elixir#

VariantModelSpeedProfile
Phoenix on the BEAM (start)BEAM processes0.45 (0.55 with the JIT)Preemptive scheduling, per-process GC, supervisors. Very flat p99. Best for rt.
+ Distributed ErlangNodes form a cluster with built-in presence and pub/sub. Replaces a separate Realtime Hub backbone.
+ NIFs×3 on CPU-heavy kindsNative code for heavy work. Not preempted, and a crash takes down the whole VM: blast radius becomes "Process". The VM stops absorbing the supervisor's crashes.

Network partitions cause split-brain incidents: the Split brain incident loses half of one clustered Hub's messages, and chaos experiments find it first. A BEAM Realtime Hub clusters without the option.

Rust#

VariantModelSpeedProfile
Tokio with Axum (start)Async thread pool1.40Lowest memory and no GC. Cooperative scheduling: a CPU-heavy task blocks one worker thread, a milder form of the Node.js problem. A task panic fails that task only.
+ spawn_blockingMoves CPU-heavy kinds to a blocking pool. Removes the tail. The pool is eight threads at about 2 MB each.
Allocator: jemalloc×1.05Less fragmentation in long-running services. +5% memory at start.

Rust's cost is in Dev Hours, not on the server: velocity 0.60 and 12-minute builds, which lower deploy frequency (Team).

Realtime connections#

rt holds a connection open per online user. Memory per connection decides how many fit on a box.

Runtime and variantMemory per connectionNotes
PHP-FPM, Gunicorn sync, UnicornNot supportedA worker per connection would hold the whole pool.
Java with platform threads~1 MBA thread per connection.
Ruby (Action Cable on Puma)~100 KB
Python ASGI~30 KBUvicorn ASGI holds them.
Node.js~20 KB
Go~10 KBGoroutine plus buffers.
Java with virtual threads~10 KB
Rust~5 KB
Elixir~5 KBPhoenix reached 2 million connections on one large box in 2015.

A runtime that cannot hold connections needs a Realtime Hub on a different runtime, which is a real reason teams add a second language. The Realtime Hub (Components) holds them. Its box gives 8 GB to connections, divided by the row above, so a BEAM Hub holds about 1.6 million connections, a Go one 800,000, and a Java one 8,000. An App Server holds the same rt connections itself while its runtime can. It refuses them when it cannot: PHP-FPM and Gunicorn sync never hold one, on an App Server or on a Hub.

Frameworks#

A framework belongs to one language. It multiplies three terms: CPU overhead per request, velocity, and a special effect. The default for each language is "No framework".

FrameworkLanguageCPU overheadVelocitySpecial effect
No frameworkAny1.0×0.8×Fast and slow to build features. Tech debt grows faster as the codebase grows.
LaravelPHP1.8×1.45×Eloquent ORM: N+1 queries multiply q-read by 3 until research "Eager loading". Queues and scheduler built in: background Features (digests, exports, notifications, webhooks, push) cost −20% Dev Hours.
SymfonyPHP1.5× in FPM1.25×Strict structure: −30% tech debt growth and a smaller coordination penalty.
RailsRuby1.5×1.50×Active Record N+1 (×3 q-read until "Eager loading"). Convention over configuration: new hires are productive 2× sooner. Hotwire: page value +20%.
SinatraRuby1.1×1.10×Light.
DjangoPython1.4×1.40×ORM N+1. The admin site: internal tools (dashboards, audit log, status page, data export) cost −30% Dev Hours.
FlaskPython1.1×1.10×Light.
FastAPIPython1.15× (ASGI only)1.30×Validation with Pydantic. ai value +15% (best ecosystem for model glue). The overhead applies only on an ASGI server; 10 MB.
ExpressNode.js1.1×1.20×Minimal and everywhere. Middleware sprawl: +10% tech debt growth.
FastifyNode.js1.0×1.15×Schema-based serialization: read CPU −20%. 5 MB.
NestJSNode.js1.3×1.30×Structure for large teams: the coordination penalty is 30% smaller (Team).
Next.jsNode.js1.6× on page1.35×Server rendering and incremental static regeneration: page value ×1.5, and 40% of page becomes CDN-cacheable. 30 MB.
Spring BootJava1.3×, +200 MB1.10×Structure for large teams (coordination penalty −30%). Slow startup (+15 s).
QuarkusJava1.1×1.05×Built for native images: halves GraalVM build time. 100 MB.
ASP.NET CoreC#1.05×1.15×Fast and complete.
GinGo1.05×1.10×Light router.
FiberGo1.0× (fasthttp)1.15×Express-style API. Inherits fasthttp limits.
PhoenixElixir1.1×1.30×Channels for rt built in. LiveView: page value +30%, and each connected user holds a BEAM process (memory per user).
AxumRust1.0×1.15×Built on Tokio and Tower.
Actix WebRust0.95×1.10×Among the fastest in benchmarks.

Worked tradeoffs#

These scenarios are the dominance test in prose. Each runtime wins at least one.

SituationStrong choiceWhy
Garage, tiny team, mostly page and read, cheap hiresPHP-FPM with Laravel, or RailsVelocity matters more than CPU. One box is enough. Shared nothing means no leaks.
Slow database you cannot fix yet, lots of I/O waitNode.js, Go, or TokioWaiting does not hold a worker.
Many login or upload requests on the same serviceGo, Java, or ElixirNo event-loop stall. Node.js stalls its loop until the libuv pool Research.
rt at scaleElixir, then Go or RustMemory per connection and flat p99.
Steady, CPU-bound, high volume, long-lived processesJava with ZGC, C#, or RustHighest throughput per dollar once warm.
Spiky traffic on Serverless FunctionsGo or RustCold starts. A GraalVM native image fits here too: it starts in under 100 ms.
ai glue, embeddings, rerankingPython with FastAPIThe ML ecosystem bonus: +20% on ai in Python, and FastAPI adds 15%.
A hot path service where p99 matters more than Dev Hours (Discord read states)RustNo GC pauses. Discord moved a service from Go to Rust for this reason in 2020.
40 engineers in one codebaseSymfonySymfony, Spring Boot and NestJS cut coordination by 30%, and Symfony also cuts tech debt growth by 30%.
Pre-IPO cost cutting on a large fleetGo or Rust on ARM instancesLow memory and good speed on cheaper instances.

Migrations#

Changing a service's language is a migration. Changing a variant or framework inside the same language is an adoption. Both cost Dev Hours and take calendar time. During a migration, the service runs both versions, and traffic shifts in steps (Team: migrations). The "big rewrite" is a real trap: it stops feature work while the competition ships.