{
  "@context": "https://schema.org",
  "@id": "https://aggr.aymericbeaumet.com/items/dev-37signals-com/2026-10-01-warming-up-the-puma-master-before-it-forks/#webpage",
  "@type": [
    "WebPage",
    "ArchiveComponent"
  ],
  "articleBody": "[Basecamp 5](https://basecamp.com) runs on [Puma](https://github.com/puma/puma) in cluster mode: one master process with `preload_app!` and 63 single-threaded workers per host, deployed as a Docker container with [Kamal](https://dev.37signals.com/kamal-2/).\n\nWe serve Basecamp from several sites. Each site has its own web hosts and a read replica of the database, and writes go to a single primary database in one of them.\n\nOn our busiest hosts, each deploy left up to 2,000 requests waiting while the new workers warmed up. We reduced those queues by running signed-in requests through the app in the Puma master, before it forked the workers.\n\n## Why 63 single-threaded workers?\n\nBasecamp has always served web requests from processes rather than threads. It ran on Unicorn, which only does processes, until we moved to Puma in January 2025, and we kept the same setup:\n\n```ruby\nworkers (Concurrent.physical_processor_count * 1.3).ceil\nthreads 1, 1\npreload_app!\n```\n\nOn a 48-core host that’s 63 workers, each handling one request at a time.\n\nWe chose 1.3 after benchmarking HEY in 2023, when we [moved our apps out of the cloud](https://dev.37signals.com/bringing-our-apps-back-home/) and onto our own hardware. We tested several combinations of workers and threads with a mix of GET and POST requests on a 32-vCPU VM. Every multithreaded configuration we tested was slower and handled fewer requests than single-threaded workers. Adding workers beyond about 1.2 to 1.3 per vCPU brought little benefit.\n\nThe threaded workers spent a lot of their time waiting for Ruby’s global VM lock. That made single-threaded workers a good fit for this workload, and we use the same setup for Basecamp. An app that spends more time waiting on its database or other services may benefit from more threads, so benchmark your own app.\n\nThe other reason is the app itself. Basecamp has class-level state in places and has never needed to be thread-safe. With one request per process, it still doesn’t.\n\nProcesses do use more memory than threads, and `preload_app!` reduces the difference. The master loads the app once and the workers share its memory through copy-on-write until they write to it. Shopify’s [comparison of Ruby execution models](https://shopify.engineering/ruby-execution-models) explains the trade-off well. In the HEY benchmark the best setup came to about 260 MB of PSS per core, where PSS counts each shared page once, split between the processes using it, and the gap to a threaded setup was smaller than we’d expected.\n\nTwo things about this setup matter for the rest of the post. A worker that’s compiling or loading something is fully blocked — there’s no other thread to pick up the next request. And whatever the master has in memory before it forks, all 63 workers share. Whatever they build after the fork, they build 63 times.\n\n## What happens when we deploy\n\nKamal starts the new container alongside the old one, and [kamal-proxy](https://github.com/basecamp/kamal-proxy) moves the host’s traffic across as soon as the health check passes. At that moment, the new workers have handled health checks but no customer requests.\n\n`preload_app!` means the master loads the app once and the workers inherit it through `fork`. That covers the code. It doesn’t cover anything Ruby and Rails set up on first use:\n\n- **YJIT compiled code.** [YJIT](https://dev.37signals.com/yjit-is-fast/) compiles a method once it’s been called a certain number of times. The master calls very little during boot, so every worker compiles the same methods again on its own first requests.\n- **Compiled templates.** Action View turns each ERB template into a Ruby method the first time it’s rendered.\n- **The schema cache.** Active Record reads each model’s columns from the database the first time that model is used.\n- **Inline caches and memoized values** throughout Ruby, Rails and the app.\n\nAll 63 workers did all of this at once, while serving the traffic the old container had been handling a second earlier. In the test environment with YJIT on, the first request to a project page on a cold process took 652 ms, 151 ms of it YJIT compiling. The same request to a warm process took 28 ms. In production, CPU time per request peaked at around 200 ms while kamal-proxy moved traffic to the new container, against about 30 ms once the workers had warmed up.\n\nA host with spare CPU absorbs this. Every one of our web hosts has 48 cores and 63 workers, but each Amsterdam host serves around 250 requests per second, against 25 to 60 at our other sites. In Amsterdam the slow first requests turned into a queue. At a peak-hour deploy, the Puma backlog on an Amsterdam host reached anywhere from 250 to 2,238 requests, and kamal-proxy’s p99 response time hit about 10 seconds.\n\n[Eron](https://dev.37signals.com/author/eron/), our Director of Operations, had been tracking this since June. Another server in Amsterdam would help, but it would take weeks to arrive, so we also wanted to make deploys cheaper on the hardware we already had.\n\n## What didn’t work\n\nWe tried a few things first. In June, [Donal](https://dev.37signals.com/author/donal/) tested the first two on a single Amsterdam host, comparing it with its neighbors, and they ruled out two likely causes.\n\n**Warming each worker’s database connections.** Puma’s `before_fork` hook clears the master’s connections, and each worker opened its own on its first request. Opening them in `before_worker_boot` instead made no difference. Queries on a freshly booted production host were already under a millisecond, so connections weren’t the problem.\n\n**A synthetic request in each worker.** Next, each worker made a few requests in `before_worker_boot` to an internal controller that touched every model. That ran the middleware, routing and Active Record paths, but it ran them in 63 workers at once — exactly the CPU spike we were trying to avoid. And a request with no real data renders no real views, so most of the app stayed cold.\n\n**Spreading YJIT compilation out.** Delaying YJIT in each worker by a random interval spread the compiling out over a few minutes, but every worker still ran interpreted until its delay ended. The queue didn’t change.\n\n**Reforking from a warm worker.** This is what Shopify’s [Pitchfork](https://github.com/Shopify/pitchfork) does: let one worker serve traffic until it’s warm, then fork the others from it. Puma has an experimental version called `fork_worker`, and on beta it worked — the reforked workers were warm after three to five requests, where fresh ones took up to 30 seconds. But with `fork_worker` the template is worker 0, and it keeps serving requests. If it exits, the workers waiting to be forked never start ([puma/puma#3596](https://github.com/puma/puma/issues/3596)). If it gets no traffic, the refork never happens, which is what we saw on beta. Instacart have a `mold_worker` patch that promotes a warm worker to a template that stops serving, but it isn’t in a Puma release. We have a branch of it, and we may come back to it.\n\nThat last experiment did show us where the fix was, though. Everything a warm worker has that a cold one lacks is in its memory, and `fork` copies memory. The master already has the app loaded. It just never runs it.\n\n## Run the requests in the master\n\nSo now, before the master binds its socket and forks, it makes the app’s own requests, in-process, the way a signed-in user would.\n\nRack has a hook for exactly this. [`Rack::Builder#warmup`](https://rubydoc.info/gems/rack/Rack/Builder#warmup-instance_method) takes a block that’s called once with the built app, before the server starts. `rails server` builds the app from `config.ru`, so the change to boot is one line:\n\n```ruby\nrequire_relative \"config/environment\"\n\nwarmup { WarmUp.configured.run } if ENV[\"WARM_UP\"]\n\nrun Rails.application\n```\n\nWith `preload_app!` this runs in the master, and the workers inherit whatever it did. Puma binds its socket after the app is built, so until the warm-up finishes the health check’s connection is refused and kamal-proxy keeps retrying. No request reaches a worker that hasn’t been warmed.\n\nThe warm-up has three steps. After precompiling the views, it gives the page requests and schema loading a shared 20-second budget, checked before each page or model.\n\n### 1\\. Precompile the views\n\n[`actionview_precompiler`](https://github.com/jhawthorn/actionview_precompiler) reads every template for its `render` calls and compiles each one with the locals it’s passed. For us that’s 1,394 templates in about two seconds. A first request to a project page then compiles 2 templates instead of 44.\n\n### 2\\. Request the pages, signed in\n\nA small browser class makes the requests through `Rack::MockRequest`, with the two cookies a real sign-in sets, then goes back for each page’s lazy Turbo frames:\n\n```ruby\nclass WarmUp::Browser\n  def initialize(signed_in_as:)\n    @client = Rack::MockRequest.new(Rails.application)\n    @headers = {\n      \"HTTP_USER_AGENT\" => \"Basecamp warm-up\",\n      \"HTTP_COOKIE\" => cookie_for(signed_in_as),\n      \"bc3.warm_up\" => true\n    }\n  end\n\n  def visit(path)\n    page = get(path)\n    frames_in(page).each { |id, src| get(src, \"HTTP_TURBO_FRAME\" => id) }\n  end\n\n  private\n    def get(path, headers = {})\n      @client.get(\"https://#{host}#{path}\", @headers.merge(headers))\n    end\n\n    def frames_in(page)\n      Nokogiri::HTML5(page.body).css(\"turbo-frame[src]\").map { |frame| [ frame[\"id\"], frame[\"src\"] ] }\n    end\nend\n```\n\n**The requests are signed in.** The user is a monitoring account we already use for automated checks, and the pages are its own project, Campfire, to-dos, documents and messages. Public pages weren’t enough: after warming up with signed-out pages only, the first signed-in request to the projects page still took 131 ms, because authentication, the signed-in controllers and their views had never run. With signed-in pages it took 40 ms. `cookie_for` writes the same signed cookie the sign-in controller does, using the app’s own cookie jar, so there’s no API token and no secret to store.\n\n**The frames are followed.** The busiest HTML requests in production aren’t pages at all but Turbo frames — the sidebar badge, the inbox, the navigation menus. The browser parses each page and requests its `<turbo-frame src>` URLs with the `Turbo-Frame` header, so those controllers and views get warmed too. Our first four pages turned into 60 requests.\n\n**The requests are excluded from rate limiting.** They are internal, so they do not count against the rate limits that apply to real visitors.\n\n### 3\\. Load the rest of the schema\n\nThe page requests load the schema for the models they touch. The last step loads the rest, from the read replica:\n\n```ruby\nApplicationRecord.reading do\n  models.lazy.take_while { time_left? }.each { |model| model.load_schema if model.table_exists? }\nend\n```\n\nThe step checks 261 models and loads any schema information still missing. Those database round trips add up when the primary is far away: outside a request, Active Record uses the writing role, and from a host a long way from the primary each round trip is tens of milliseconds. Reading from the local replica brings the step down from about 20 seconds to 3.5. The pages go first because they load most of the schema anyway. If the time budget runs out, the step stops, logs how many models it got through, and the workers load the rest on first use like they always did.\n\nRails can also load the schema from a dumped cache file at boot (`bin/rails db:schema:cache:dump`), which would make this step unnecessary. We don’t ship one in our image yet, because the dump needs a database to read from at build time, and we have several databases to cover. It’s on the list.\n\n## What to close before the fork\n\nRunning requests in the master opens things the master never opened before, and every worker inherits them. Two processes writing to the same socket will corrupt each other’s traffic, so you need to know what’s open before you fork.\n\nThe way to find out is to list the master’s open file descriptors — `ls -l /proc/<pid>/fd` — before and after a warm-up, in an environment set up like production. Development wasn’t enough for us: it stores files on disk, so our S3 connections only showed up in production. Then, for each thing that’s open, check how its library handles a fork. We found three kinds:\n\n- **Already handled.** Plenty of libraries detect a fork on their own, either by recording the PID they connected from and reconnecting in the child, by opening per-process files, or by resetting their thread pools. Redis clients, metrics libraries and concurrency libraries tend to be in this group. Check, but you probably don’t need to do anything.\n- **Already closed.** Database connections are the classic one, and most Puma configs already clear them in `before_fork`. Anything else that’s opened per process — we have a SQLite cache the workers open on boot — needs closing when the warm-up finishes.\n- **Needs a new step.** HTTP clients with keep-alive connections are the ones to look for: cloud SDKs with connection pools, tracing exporters, error reporters. They usually have no fork handling at all. We empty the aws-sdk connection pools in `before_fork`, and we run the warm-up untraced so the OpenTelemetry exporter never opens its connection to Tempo in the first place.\n\nOnce that’s done, `before_fork` finishes with `Process.warmup`, which Ruby 3.3 added for this purpose: a major GC, a heap compaction, and every surviving object promoted to the old generation, so the memory pages the workers share change as little as possible afterwards.\n\n## Choosing the pages\n\nThe first list was the four pages that ran the busiest requests on beta. Once the warm-up was live, production showed us which endpoints were still cold. For one deploy, we compared each endpoint’s mean duration in the six minutes after kamal-proxy moved traffic to the new container with the same endpoint an hour later, then multiplied the difference by the number of requests in those six minutes. That gives the extra time each endpoint cost us because it was cold:\n\n| Endpoint            | Cold   | Warm   | Requests in 6 min | Extra seconds |\n| ------------------- | ------ | ------ | ----------------- | ------------- |\n| Campfire            | 246 ms | 70 ms  | 6,490             | 1,140         |\n| Projects (JSON API) | 84 ms  | 50 ms  | 22,077            | 771           |\n| Docs & Files        | 262 ms | 177 ms | 4,996             | 421           |\n| To-dos tool         | 205 ms | 113 ms | 4,018             | 371           |\n| To-dos (JSON API)   | 33 ms  | 16 ms  | 18,738            | 320           |\n\nThe pages already in the warm-up showed what to expect: the project page kept a 36 ms gap after a deploy, and the to-do page 10 ms. We’ve proposed adding these five requests, and expect them to add about five to seven seconds to the page step. The two JSON endpoints were a surprise. The warm-up’s page list had no API requests in it, so nothing on the API path had run before the first real request: not the API controllers, and not the Jbuilder templates rendering real records. Precompiling the views covers JSON templates too, but it isn’t a substitute for running the request.\n\n## Results\n\nThe warm-up is on for all 68 web hosts. With the first four pages it took 12 to 16 seconds per host: about 2 seconds to precompile the views, 7 to 9 for the 60 requests, and 3.5 for the schema. Deploys take that much longer per host, and we raised the deploy timeout from 30 to 60 seconds to cover it.\n\nIn Amsterdam, at a peak-hour deploy:\n\n| During deploy                  | Before             | After           |\n| ------------------------------ | ------------------ | --------------- |\n| Peak Puma backlog per host     | 250–2,238 requests | 19–223 requests |\n| Peak kamal-proxy p99           | about 10 s         | 2.4–4.8 s       |\n| Peak CPU time per request      | 201–214 ms         | 88–132 ms       |\n| Peak database time per request | 56–69 ms           | 39–47 ms        |\n\nThe deploy in the middle, with four hosts warmed and four not, shows why every host needed the warm-up. Each warmed host recovered faster on its own: mean request duration peaked at 130 to 173 ms, against 203 to 311 ms on the hosts that weren’t warmed. But the backlog on all eight was about the same, because they were all waiting on the same database.\n\nMemory came down too. The workers now share compiled templates, YJIT code and the schema with the master instead of each building their own copy. On beta, the view precompiler alone took a busy worker’s private memory from 174–202 MB to 119–135 MB. Thirty minutes after the deploy, the web containers used about 39 GB less memory than the previous day’s containers at the same age and traffic. Amsterdam served most of our traffic at the times we tested. In Amsterdam, each new container used about 2 GB less just after traffic moved to it, which lowers the peak while the old and new containers overlap.\n\n## Working with Claude\n\nClaude Code helped throughout. It combed through the per-worker backlogs and per-endpoint timings in [Prometheus](https://dev.37signals.com/kamal-prometheus/) and Loki after each deploy, worked out the cold-versus-warm cost of each endpoint, and prepared the changes and the pull request descriptions with the benchmarks in them. We decided what to try, deployed it and read the results.\n\n## If you do this\n\n**Warm the master before it forks.** Compile common code and templates and load their schema in the master, so workers inherit that work. With `preload_app!`, `Rack::Builder#warmup` runs before the workers start accepting traffic.\n\n**Use the app’s real requests.** Public pages, internal endpoints and synthetic queries warm the paths they run and nothing else. Signed-in requests to real records, frames included, run what production runs.\n\n**Measure the cold penalty per endpoint.** The difference between an endpoint’s cold and warm duration, times its request count after a deploy, ranks the pages worth adding. Ours weren’t the ones we’d have guessed, and two of them were JSON.\n\n**Check what the warm-up leaves open.** List the master’s file descriptors after a warm-up and account for every one before the fork. Two of ours needed changes.\n\n**Set a time budget.** A warm-up that runs long on one slow host fails the deploy on that host. Ours gives the page requests and schema loading a shared 20-second budget, checked before each page or model, puts the most valuable pages first, and logs what it skipped.\n\nReforking from a warm worker, as Pitchfork does, solves the same problem continuously rather than once at boot, and it would warm paths no fixed list of pages covers. We may still get there: [our branch](https://github.com/basecamp/puma/pull/1) brings Instacart’s `mold_worker` up to date with Puma’s main branch and fixes the bugs we found in it. But warming the master works with the Puma we already run, took a few days to implement, and substantially reduced the queues after deployment.\n",
  "authors": [
    "Lewis Buckley"
  ],
  "captured_at": "2026-10-02T15:44:26.851147349+00:00",
  "category": "programming",
  "content_html": "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://basecamp.com\">Basecamp 5</a> runs on <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/puma/puma\">Puma</a> in cluster mode: one master process with <code>preload_app!</code> and 63 single-threaded workers per host, deployed as a Docker container with <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://dev.37signals.com/kamal-2/\">Kamal</a>.</p>\n<p>We serve Basecamp from several sites. Each site has its own web hosts and a read replica of the database, and writes go to a single primary database in one of them.</p>\n<p>On our busiest hosts, each deploy left up to 2,000 requests waiting while the new workers warmed up. We reduced those queues by running signed-in requests through the app in the Puma master, before it forked the workers.</p>\n<h2>Why 63 single-threaded workers?</h2>\n<p>Basecamp has always served web requests from processes rather than threads. It ran on Unicorn, which only does processes, until we moved to Puma in January 2025, and we kept the same setup:</p>\n<pre><code class=\"language-ruby\"><span class=\"code-snippet\" data-language=\"Ruby\"><span class=\"syntax-source syntax-ruby\">workers <span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span><span class=\"syntax-support syntax-class syntax-ruby\">Concurrent</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>physical_processor_count <span class=\"syntax-keyword syntax-operator syntax-arithmetic syntax-ruby\">*</span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">1.3</span><span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>ceil\nthreads <span class=\"syntax-constant syntax-numeric syntax-ruby\">1</span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-constant syntax-numeric syntax-ruby\">1</span>\npreload_app!\n</span></span></code></pre>\n<p>On a 48-core host that’s 63 workers, each handling one request at a time.</p>\n<p>We chose 1.3 after benchmarking HEY in 2023, when we <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://dev.37signals.com/bringing-our-apps-back-home/\">moved our apps out of the cloud</a> and onto our own hardware. We tested several combinations of workers and threads with a mix of GET and POST requests on a 32-vCPU VM. Every multithreaded configuration we tested was slower and handled fewer requests than single-threaded workers. Adding workers beyond about 1.2 to 1.3 per vCPU brought little benefit.</p>\n<p>The threaded workers spent a lot of their time waiting for Ruby’s global VM lock. That made single-threaded workers a good fit for this workload, and we use the same setup for Basecamp. An app that spends more time waiting on its database or other services may benefit from more threads, so benchmark your own app.</p>\n<p>The other reason is the app itself. Basecamp has class-level state in places and has never needed to be thread-safe. With one request per process, it still doesn’t.</p>\n<p>Processes do use more memory than threads, and <code>preload_app!</code> reduces the difference. The master loads the app once and the workers share its memory through copy-on-write until they write to it. Shopify’s <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://shopify.engineering/ruby-execution-models\">comparison of Ruby execution models</a> explains the trade-off well. In the HEY benchmark the best setup came to about 260 MB of PSS per core, where PSS counts each shared page once, split between the processes using it, and the gap to a threaded setup was smaller than we’d expected.</p>\n<p>Two things about this setup matter for the rest of the post. A worker that’s compiling or loading something is fully blocked — there’s no other thread to pick up the next request. And whatever the master has in memory before it forks, all 63 workers share. Whatever they build after the fork, they build 63 times.</p>\n<h2>What happens when we deploy</h2>\n<p>Kamal starts the new container alongside the old one, and <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/basecamp/kamal-proxy\">kamal-proxy</a> moves the host’s traffic across as soon as the health check passes. At that moment, the new workers have handled health checks but no customer requests.</p>\n<p><code>preload_app!</code> means the master loads the app once and the workers inherit it through <code>fork</code>. That covers the code. It doesn’t cover anything Ruby and Rails set up on first use:</p>\n<ul>\n<li><strong>YJIT compiled code.</strong> <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://dev.37signals.com/yjit-is-fast/\">YJIT</a> compiles a method once it’s been called a certain number of times. The master calls very little during boot, so every worker compiles the same methods again on its own first requests.</li>\n<li><strong>Compiled templates.</strong> Action View turns each ERB template into a Ruby method the first time it’s rendered.</li>\n<li><strong>The schema cache.</strong> Active Record reads each model’s columns from the database the first time that model is used.</li>\n<li><strong>Inline caches and memoized values</strong> throughout Ruby, Rails and the app.</li>\n</ul>\n<p>All 63 workers did all of this at once, while serving the traffic the old container had been handling a second earlier. In the test environment with YJIT on, the first request to a project page on a cold process took 652 ms, 151 ms of it YJIT compiling. The same request to a warm process took 28 ms. In production, CPU time per request peaked at around 200 ms while kamal-proxy moved traffic to the new container, against about 30 ms once the workers had warmed up.</p>\n<p>A host with spare CPU absorbs this. Every one of our web hosts has 48 cores and 63 workers, but each Amsterdam host serves around 250 requests per second, against 25 to 60 at our other sites. In Amsterdam the slow first requests turned into a queue. At a peak-hour deploy, the Puma backlog on an Amsterdam host reached anywhere from 250 to 2,238 requests, and kamal-proxy’s p99 response time hit about 10 seconds.</p>\n<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://dev.37signals.com/author/eron/\">Eron</a>, our Director of Operations, had been tracking this since June. Another server in Amsterdam would help, but it would take weeks to arrive, so we also wanted to make deploys cheaper on the hardware we already had.</p>\n<h2>What didn’t work</h2>\n<p>We tried a few things first. In June, <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://dev.37signals.com/author/donal/\">Donal</a> tested the first two on a single Amsterdam host, comparing it with its neighbors, and they ruled out two likely causes.</p>\n<p><strong>Warming each worker’s database connections.</strong> Puma’s <code>before_fork</code> hook clears the master’s connections, and each worker opened its own on its first request. Opening them in <code>before_worker_boot</code> instead made no difference. Queries on a freshly booted production host were already under a millisecond, so connections weren’t the problem.</p>\n<p><strong>A synthetic request in each worker.</strong> Next, each worker made a few requests in <code>before_worker_boot</code> to an internal controller that touched every model. That ran the middleware, routing and Active Record paths, but it ran them in 63 workers at once — exactly the CPU spike we were trying to avoid. And a request with no real data renders no real views, so most of the app stayed cold.</p>\n<p><strong>Spreading YJIT compilation out.</strong> Delaying YJIT in each worker by a random interval spread the compiling out over a few minutes, but every worker still ran interpreted until its delay ended. The queue didn’t change.</p>\n<p><strong>Reforking from a warm worker.</strong> This is what Shopify’s <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/Shopify/pitchfork\">Pitchfork</a> does: let one worker serve traffic until it’s warm, then fork the others from it. Puma has an experimental version called <code>fork_worker</code>, and on beta it worked — the reforked workers were warm after three to five requests, where fresh ones took up to 30 seconds. But with <code>fork_worker</code> the template is worker 0, and it keeps serving requests. If it exits, the workers waiting to be forked never start (<a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/puma/puma/issues/3596\">puma/puma#3596</a>). If it gets no traffic, the refork never happens, which is what we saw on beta. Instacart have a <code>mold_worker</code> patch that promotes a warm worker to a template that stops serving, but it isn’t in a Puma release. We have a branch of it, and we may come back to it.</p>\n<p>That last experiment did show us where the fix was, though. Everything a warm worker has that a cold one lacks is in its memory, and <code>fork</code> copies memory. The master already has the app loaded. It just never runs it.</p>\n<h2>Run the requests in the master</h2>\n<p>So now, before the master binds its socket and forks, it makes the app’s own requests, in-process, the way a signed-in user would.</p>\n<p>Rack has a hook for exactly this. <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://rubydoc.info/gems/rack/Rack/Builder#warmup-instance_method\"><code>Rack::Builder#warmup</code></a> takes a block that’s called once with the built app, before the server starts. <code>rails server</code> builds the app from <code>config.ru</code>, so the change to boot is one line:</p>\n<pre><code class=\"language-ruby\"><span class=\"code-snippet\" data-language=\"Ruby\"><span class=\"syntax-source syntax-ruby\"><span class=\"syntax-meta syntax-require syntax-ruby\"><span class=\"syntax-keyword syntax-other syntax-special-method syntax-ruby\">require_relative</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>config/environment<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span></span>\n\nwarmup <span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">{</span> <span class=\"syntax-support syntax-class syntax-ruby\">WarmUp</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>configured<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>run <span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">}</span> <span class=\"syntax-keyword syntax-control syntax-ruby\">if</span> <span class=\"syntax-meta syntax-environment-variable syntax-ruby\"><span class=\"syntax-variable syntax-other syntax-constant syntax-ruby\">ENV</span>[<span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>WARM_UP<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span>]</span>\n\nrun <span class=\"syntax-support syntax-class syntax-ruby\">Rails</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>application\n</span></span></code></pre>\n<p>With <code>preload_app!</code> this runs in the master, and the workers inherit whatever it did. Puma binds its socket after the app is built, so until the warm-up finishes the health check’s connection is refused and kamal-proxy keeps retrying. No request reaches a worker that hasn’t been warmed.</p>\n<p>The warm-up has three steps. After precompiling the views, it gives the page requests and schema loading a shared 20-second budget, checked before each page or model.</p>\n<h3>1. Precompile the views</h3>\n<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/jhawthorn/actionview_precompiler\"><code>actionview_precompiler</code></a> reads every template for its <code>render</code> calls and compiles each one with the locals it’s passed. For us that’s 1,394 templates in about two seconds. A first request to a project page then compiles 2 templates instead of 44.</p>\n<h3>2. Request the pages, signed in</h3>\n<p>A small browser class makes the requests through <code>Rack::MockRequest</code>, with the two cookies a real sign-in sets, then goes back for each page’s lazy Turbo frames:</p>\n<pre><code class=\"language-ruby\"><span class=\"code-snippet\" data-language=\"Ruby\"><span class=\"syntax-source syntax-ruby\"><span class=\"syntax-meta syntax-class syntax-ruby\"><span class=\"syntax-keyword syntax-control syntax-class syntax-ruby\">class</span></span><span class=\"syntax-meta syntax-class syntax-ruby\"> </span><span class=\"syntax-meta syntax-class syntax-ruby\"><span class=\"syntax-entity syntax-name syntax-class syntax-ruby\"><span class=\"syntax-support syntax-other syntax-namespace syntax-ruby\">WarmUp</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span>Browser</span></span><span class=\"syntax-meta syntax-class syntax-ruby\">\n  </span><span class=\"syntax-meta syntax-function syntax-ruby\"><span class=\"syntax-keyword syntax-control syntax-def syntax-ruby\">def</span></span><span class=\"syntax-meta syntax-function syntax-ruby\"> <span class=\"syntax-entity syntax-name syntax-function syntax-ruby\">initialize</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-variable syntax-parameter syntax-ruby\">signed_in_as</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-default-value syntax-ruby\"><span class=\"syntax-punctuation syntax-separator syntax-ruby\">:</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span></span>\n    <span class=\"syntax-variable syntax-other syntax-readwrite syntax-instance syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-variable syntax-ruby\">@</span>client</span> <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> <span class=\"syntax-support syntax-class syntax-ruby\">Rack</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span><span class=\"syntax-support syntax-class syntax-ruby\">MockRequest</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span><span class=\"syntax-keyword syntax-other syntax-special-method syntax-ruby\">new</span><span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span><span class=\"syntax-support syntax-class syntax-ruby\">Rails</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>application<span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span>\n    <span class=\"syntax-variable syntax-other syntax-readwrite syntax-instance syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-variable syntax-ruby\">@</span>headers</span> <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> <span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">{</span>\n      <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>HTTP_USER_AGENT<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span> <span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-ruby\">=&gt;</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>Basecamp warm-up<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span>\n      <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>HTTP_COOKIE<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span> <span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-ruby\">=&gt;</span> cookie_for<span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span>signed_in_as<span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span>\n      <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>bc3.warm_up<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span> <span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-ruby\">=&gt;</span> <span class=\"syntax-constant syntax-language syntax-ruby\">true</span>\n    <span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">}</span>\n  <span class=\"syntax-keyword syntax-control syntax-ruby\">end</span>\n\n  <span class=\"syntax-meta syntax-function syntax-ruby\"><span class=\"syntax-keyword syntax-control syntax-def syntax-ruby\">def</span></span><span class=\"syntax-meta syntax-function syntax-ruby\"> <span class=\"syntax-entity syntax-name syntax-function syntax-ruby\">visit</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-variable syntax-parameter syntax-ruby\">path</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span></span>\n    page <span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span> get<span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span>path<span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span>\n    frames_in<span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span>page<span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>each <span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">{</span> <span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-parameters syntax-begin syntax-ruby\">|</span></span><span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-variable syntax-parameter syntax-ruby\">id</span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-variable syntax-parameter syntax-ruby\">src</span><span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-parameters syntax-end syntax-ruby\">|</span></span></span> get<span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span>src<span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>HTTP_TURBO_FRAME<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span> <span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-ruby\">=&gt;</span> id<span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span> <span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">}</span>\n  <span class=\"syntax-keyword syntax-control syntax-ruby\">end</span>\n\n  <span class=\"syntax-keyword syntax-other syntax-special-method syntax-ruby\">private</span>\n    <span class=\"syntax-meta syntax-function syntax-ruby\"><span class=\"syntax-keyword syntax-control syntax-def syntax-ruby\">def</span></span><span class=\"syntax-meta syntax-function syntax-ruby\"> <span class=\"syntax-entity syntax-name syntax-function syntax-ruby\">get</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-variable syntax-parameter syntax-ruby\">path</span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-variable syntax-parameter syntax-ruby\">headers</span> </span><span class=\"syntax-meta syntax-function syntax-parameters syntax-default-value syntax-ruby\"><span class=\"syntax-keyword syntax-operator syntax-assignment syntax-ruby\">=</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-default-value syntax-ruby\"> <span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">{</span><span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">}</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span></span>\n      <span class=\"syntax-variable syntax-other syntax-readwrite syntax-instance syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-variable syntax-ruby\">@</span>client</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>get<span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span><span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>https://<span class=\"syntax-source syntax-ruby syntax-embedded syntax-source\"><span class=\"syntax-punctuation syntax-section syntax-embedded syntax-ruby\">#{</span>host<span class=\"syntax-punctuation syntax-section syntax-embedded syntax-ruby\">}</span></span><span class=\"syntax-source syntax-ruby syntax-embedded syntax-source\"><span class=\"syntax-punctuation syntax-section syntax-embedded syntax-ruby\">#{</span>path<span class=\"syntax-punctuation syntax-section syntax-embedded syntax-ruby\">}</span></span><span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> <span class=\"syntax-variable syntax-other syntax-readwrite syntax-instance syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-variable syntax-ruby\">@</span>headers</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>merge<span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span>headers<span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span><span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span>\n    <span class=\"syntax-keyword syntax-control syntax-ruby\">end</span>\n\n    <span class=\"syntax-meta syntax-function syntax-ruby\"><span class=\"syntax-keyword syntax-control syntax-def syntax-ruby\">def</span></span><span class=\"syntax-meta syntax-function syntax-ruby\"> <span class=\"syntax-entity syntax-name syntax-function syntax-ruby\">frames_in</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-variable syntax-parameter syntax-ruby\">page</span></span><span class=\"syntax-meta syntax-function syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span></span>\n      <span class=\"syntax-support syntax-class syntax-ruby\">Nokogiri</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">::</span>HTML5<span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span>page<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>body<span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>css<span class=\"syntax-punctuation syntax-definition syntax-group syntax-begin syntax-ruby\">(</span><span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>turbo-frame[src]<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span><span class=\"syntax-punctuation syntax-definition syntax-group syntax-end syntax-ruby\">)</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>map <span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">{</span> <span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-parameters syntax-begin syntax-ruby\">|</span></span><span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-variable syntax-parameter syntax-ruby\">frame</span><span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-parameters syntax-end syntax-ruby\">|</span></span></span> <span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">[</span> frame<span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">[</span><span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>id<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span><span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">]</span><span class=\"syntax-punctuation syntax-separator syntax-ruby\">,</span> frame<span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">[</span><span class=\"syntax-string syntax-quoted syntax-double syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-ruby\">&quot;</span>src<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-ruby\">&quot;</span></span><span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">]</span> <span class=\"syntax-punctuation syntax-section syntax-array syntax-ruby\">]</span> <span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">}</span>\n    <span class=\"syntax-keyword syntax-control syntax-ruby\">end</span>\n<span class=\"syntax-keyword syntax-control syntax-ruby\">end</span>\n</span></span></code></pre>\n<p><strong>The requests are signed in.</strong> The user is a monitoring account we already use for automated checks, and the pages are its own project, Campfire, to-dos, documents and messages. Public pages weren’t enough: after warming up with signed-out pages only, the first signed-in request to the projects page still took 131 ms, because authentication, the signed-in controllers and their views had never run. With signed-in pages it took 40 ms. <code>cookie_for</code> writes the same signed cookie the sign-in controller does, using the app’s own cookie jar, so there’s no API token and no secret to store.</p>\n<p><strong>The frames are followed.</strong> The busiest HTML requests in production aren’t pages at all but Turbo frames — the sidebar badge, the inbox, the navigation menus. The browser parses each page and requests its <code>&lt;turbo-frame src&gt;</code> URLs with the <code>Turbo-Frame</code> header, so those controllers and views get warmed too. Our first four pages turned into 60 requests.</p>\n<p><strong>The requests are excluded from rate limiting.</strong> They are internal, so they do not count against the rate limits that apply to real visitors.</p>\n<h3>3. Load the rest of the schema</h3>\n<p>The page requests load the schema for the models they touch. The last step loads the rest, from the read replica:</p>\n<pre><code class=\"language-ruby\"><span class=\"code-snippet\" data-language=\"Ruby\"><span class=\"syntax-source syntax-ruby\"><span class=\"syntax-support syntax-class syntax-ruby\">ApplicationRecord</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>reading <span class=\"syntax-keyword syntax-control syntax-start-block syntax-ruby\">do</span>\n  models<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>lazy<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>take_while <span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">{</span> time_left? <span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">}</span><span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>each <span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">{</span> <span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-parameters syntax-begin syntax-ruby\">|</span></span><span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-variable syntax-parameter syntax-ruby\">model</span><span class=\"syntax-meta syntax-block syntax-parameters syntax-ruby\"><span class=\"syntax-punctuation syntax-definition syntax-parameters syntax-end syntax-ruby\">|</span></span></span> model<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>load_schema <span class=\"syntax-keyword syntax-control syntax-ruby\">if</span> model<span class=\"syntax-punctuation syntax-accessor syntax-ruby\">.</span>table_exists? <span class=\"syntax-punctuation syntax-section syntax-scope syntax-ruby\">}</span>\n<span class=\"syntax-keyword syntax-control syntax-ruby\">end</span>\n</span></span></code></pre>\n<p>The step checks 261 models and loads any schema information still missing. Those database round trips add up when the primary is far away: outside a request, Active Record uses the writing role, and from a host a long way from the primary each round trip is tens of milliseconds. Reading from the local replica brings the step down from about 20 seconds to 3.5. The pages go first because they load most of the schema anyway. If the time budget runs out, the step stops, logs how many models it got through, and the workers load the rest on first use like they always did.</p>\n<p>Rails can also load the schema from a dumped cache file at boot (<code>bin/rails db:schema:cache:dump</code>), which would make this step unnecessary. We don’t ship one in our image yet, because the dump needs a database to read from at build time, and we have several databases to cover. It’s on the list.</p>\n<h2>What to close before the fork</h2>\n<p>Running requests in the master opens things the master never opened before, and every worker inherits them. Two processes writing to the same socket will corrupt each other’s traffic, so you need to know what’s open before you fork.</p>\n<p>The way to find out is to list the master’s open file descriptors — <code>ls -l /proc/&lt;pid&gt;/fd</code> — before and after a warm-up, in an environment set up like production. Development wasn’t enough for us: it stores files on disk, so our S3 connections only showed up in production. Then, for each thing that’s open, check how its library handles a fork. We found three kinds:</p>\n<ul>\n<li><strong>Already handled.</strong> Plenty of libraries detect a fork on their own, either by recording the PID they connected from and reconnecting in the child, by opening per-process files, or by resetting their thread pools. Redis clients, metrics libraries and concurrency libraries tend to be in this group. Check, but you probably don’t need to do anything.</li>\n<li><strong>Already closed.</strong> Database connections are the classic one, and most Puma configs already clear them in <code>before_fork</code>. Anything else that’s opened per process — we have a SQLite cache the workers open on boot — needs closing when the warm-up finishes.</li>\n<li><strong>Needs a new step.</strong> HTTP clients with keep-alive connections are the ones to look for: cloud SDKs with connection pools, tracing exporters, error reporters. They usually have no fork handling at all. We empty the aws-sdk connection pools in <code>before_fork</code>, and we run the warm-up untraced so the OpenTelemetry exporter never opens its connection to Tempo in the first place.</li>\n</ul>\n<p>Once that’s done, <code>before_fork</code> finishes with <code>Process.warmup</code>, which Ruby 3.3 added for this purpose: a major GC, a heap compaction, and every surviving object promoted to the old generation, so the memory pages the workers share change as little as possible afterwards.</p>\n<h2>Choosing the pages</h2>\n<p>The first list was the four pages that ran the busiest requests on beta. Once the warm-up was live, production showed us which endpoints were still cold. For one deploy, we compared each endpoint’s mean duration in the six minutes after kamal-proxy moved traffic to the new container with the same endpoint an hour later, then multiplied the difference by the number of requests in those six minutes. That gives the extra time each endpoint cost us because it was cold:</p>\n<table>\n<thead>\n<tr>\n<th>Endpoint</th>\n<th>Cold</th>\n<th>Warm</th>\n<th>Requests in 6 min</th>\n<th>Extra seconds</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Campfire</td>\n<td>246 ms</td>\n<td>70 ms</td>\n<td>6,490</td>\n<td>1,140</td>\n</tr>\n<tr>\n<td>Projects (JSON API)</td>\n<td>84 ms</td>\n<td>50 ms</td>\n<td>22,077</td>\n<td>771</td>\n</tr>\n<tr>\n<td>Docs &amp; Files</td>\n<td>262 ms</td>\n<td>177 ms</td>\n<td>4,996</td>\n<td>421</td>\n</tr>\n<tr>\n<td>To-dos tool</td>\n<td>205 ms</td>\n<td>113 ms</td>\n<td>4,018</td>\n<td>371</td>\n</tr>\n<tr>\n<td>To-dos (JSON API)</td>\n<td>33 ms</td>\n<td>16 ms</td>\n<td>18,738</td>\n<td>320</td>\n</tr>\n</tbody>\n</table>\n<p>The pages already in the warm-up showed what to expect: the project page kept a 36 ms gap after a deploy, and the to-do page 10 ms. We’ve proposed adding these five requests, and expect them to add about five to seven seconds to the page step. The two JSON endpoints were a surprise. The warm-up’s page list had no API requests in it, so nothing on the API path had run before the first real request: not the API controllers, and not the Jbuilder templates rendering real records. Precompiling the views covers JSON templates too, but it isn’t a substitute for running the request.</p>\n<h2>Results</h2>\n<p>The warm-up is on for all 68 web hosts. With the first four pages it took 12 to 16 seconds per host: about 2 seconds to precompile the views, 7 to 9 for the 60 requests, and 3.5 for the schema. Deploys take that much longer per host, and we raised the deploy timeout from 30 to 60 seconds to cover it.</p>\n<p>In Amsterdam, at a peak-hour deploy:</p>\n<table>\n<thead>\n<tr>\n<th>During deploy</th>\n<th>Before</th>\n<th>After</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Peak Puma backlog per host</td>\n<td>250–2,238 requests</td>\n<td>19–223 requests</td>\n</tr>\n<tr>\n<td>Peak kamal-proxy p99</td>\n<td>about 10 s</td>\n<td>2.4–4.8 s</td>\n</tr>\n<tr>\n<td>Peak CPU time per request</td>\n<td>201–214 ms</td>\n<td>88–132 ms</td>\n</tr>\n<tr>\n<td>Peak database time per request</td>\n<td>56–69 ms</td>\n<td>39–47 ms</td>\n</tr>\n</tbody>\n</table>\n<p>The deploy in the middle, with four hosts warmed and four not, shows why every host needed the warm-up. Each warmed host recovered faster on its own: mean request duration peaked at 130 to 173 ms, against 203 to 311 ms on the hosts that weren’t warmed. But the backlog on all eight was about the same, because they were all waiting on the same database.</p>\n<p>Memory came down too. The workers now share compiled templates, YJIT code and the schema with the master instead of each building their own copy. On beta, the view precompiler alone took a busy worker’s private memory from 174–202 MB to 119–135 MB. Thirty minutes after the deploy, the web containers used about 39 GB less memory than the previous day’s containers at the same age and traffic. Amsterdam served most of our traffic at the times we tested. In Amsterdam, each new container used about 2 GB less just after traffic moved to it, which lowers the peak while the old and new containers overlap.</p>\n<h2>Working with Claude</h2>\n<p>Claude Code helped throughout. It combed through the per-worker backlogs and per-endpoint timings in <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://dev.37signals.com/kamal-prometheus/\">Prometheus</a> and Loki after each deploy, worked out the cold-versus-warm cost of each endpoint, and prepared the changes and the pull request descriptions with the benchmarks in them. We decided what to try, deployed it and read the results.</p>\n<h2>If you do this</h2>\n<p><strong>Warm the master before it forks.</strong> Compile common code and templates and load their schema in the master, so workers inherit that work. With <code>preload_app!</code>, <code>Rack::Builder#warmup</code> runs before the workers start accepting traffic.</p>\n<p><strong>Use the app’s real requests.</strong> Public pages, internal endpoints and synthetic queries warm the paths they run and nothing else. Signed-in requests to real records, frames included, run what production runs.</p>\n<p><strong>Measure the cold penalty per endpoint.</strong> The difference between an endpoint’s cold and warm duration, times its request count after a deploy, ranks the pages worth adding. Ours weren’t the ones we’d have guessed, and two of them were JSON.</p>\n<p><strong>Check what the warm-up leaves open.</strong> List the master’s file descriptors after a warm-up and account for every one before the fork. Two of ours needed changes.</p>\n<p><strong>Set a time budget.</strong> A warm-up that runs long on one slow host fails the deploy on that host. Ours gives the page requests and schema loading a shared 20-second budget, checked before each page or model, puts the most valuable pages first, and logs what it skipped.</p>\n<p>Reforking from a warm worker, as Pitchfork does, solves the same problem continuously rather than once at boot, and it would warm paths no fixed list of pages covers. We may still get there: <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/basecamp/puma/pull/1\">our branch</a> brings Instacart’s <code>mold_worker</code> up to date with Puma’s main branch and fixes the bugs we found in it. But warming the master works with the Puma we already run, took a few days to implement, and substantially reduced the queues after deployment.</p>\n",
  "content_markdown": "[Basecamp 5](https://basecamp.com) runs on [Puma](https://github.com/puma/puma) in cluster mode: one master process with `preload_app!` and 63 single-threaded workers per host, deployed as a Docker container with [Kamal](https://dev.37signals.com/kamal-2/).\n\nWe serve Basecamp from several sites. Each site has its own web hosts and a read replica of the database, and writes go to a single primary database in one of them.\n\nOn our busiest hosts, each deploy left up to 2,000 requests waiting while the new workers warmed up. We reduced those queues by running signed-in requests through the app in the Puma master, before it forked the workers.\n\n## Why 63 single-threaded workers?\n\nBasecamp has always served web requests from processes rather than threads. It ran on Unicorn, which only does processes, until we moved to Puma in January 2025, and we kept the same setup:\n\n```ruby\nworkers (Concurrent.physical_processor_count * 1.3).ceil\nthreads 1, 1\npreload_app!\n```\n\nOn a 48-core host that’s 63 workers, each handling one request at a time.\n\nWe chose 1.3 after benchmarking HEY in 2023, when we [moved our apps out of the cloud](https://dev.37signals.com/bringing-our-apps-back-home/) and onto our own hardware. We tested several combinations of workers and threads with a mix of GET and POST requests on a 32-vCPU VM. Every multithreaded configuration we tested was slower and handled fewer requests than single-threaded workers. Adding workers beyond about 1.2 to 1.3 per vCPU brought little benefit.\n\nThe threaded workers spent a lot of their time waiting for Ruby’s global VM lock. That made single-threaded workers a good fit for this workload, and we use the same setup for Basecamp. An app that spends more time waiting on its database or other services may benefit from more threads, so benchmark your own app.\n\nThe other reason is the app itself. Basecamp has class-level state in places and has never needed to be thread-safe. With one request per process, it still doesn’t.\n\nProcesses do use more memory than threads, and `preload_app!` reduces the difference. The master loads the app once and the workers share its memory through copy-on-write until they write to it. Shopify’s [comparison of Ruby execution models](https://shopify.engineering/ruby-execution-models) explains the trade-off well. In the HEY benchmark the best setup came to about 260 MB of PSS per core, where PSS counts each shared page once, split between the processes using it, and the gap to a threaded setup was smaller than we’d expected.\n\nTwo things about this setup matter for the rest of the post. A worker that’s compiling or loading something is fully blocked — there’s no other thread to pick up the next request. And whatever the master has in memory before it forks, all 63 workers share. Whatever they build after the fork, they build 63 times.\n\n## What happens when we deploy\n\nKamal starts the new container alongside the old one, and [kamal-proxy](https://github.com/basecamp/kamal-proxy) moves the host’s traffic across as soon as the health check passes. At that moment, the new workers have handled health checks but no customer requests.\n\n`preload_app!` means the master loads the app once and the workers inherit it through `fork`. That covers the code. It doesn’t cover anything Ruby and Rails set up on first use:\n\n- **YJIT compiled code.** [YJIT](https://dev.37signals.com/yjit-is-fast/) compiles a method once it’s been called a certain number of times. The master calls very little during boot, so every worker compiles the same methods again on its own first requests.\n- **Compiled templates.** Action View turns each ERB template into a Ruby method the first time it’s rendered.\n- **The schema cache.** Active Record reads each model’s columns from the database the first time that model is used.\n- **Inline caches and memoized values** throughout Ruby, Rails and the app.\n\nAll 63 workers did all of this at once, while serving the traffic the old container had been handling a second earlier. In the test environment with YJIT on, the first request to a project page on a cold process took 652 ms, 151 ms of it YJIT compiling. The same request to a warm process took 28 ms. In production, CPU time per request peaked at around 200 ms while kamal-proxy moved traffic to the new container, against about 30 ms once the workers had warmed up.\n\nA host with spare CPU absorbs this. Every one of our web hosts has 48 cores and 63 workers, but each Amsterdam host serves around 250 requests per second, against 25 to 60 at our other sites. In Amsterdam the slow first requests turned into a queue. At a peak-hour deploy, the Puma backlog on an Amsterdam host reached anywhere from 250 to 2,238 requests, and kamal-proxy’s p99 response time hit about 10 seconds.\n\n[Eron](https://dev.37signals.com/author/eron/), our Director of Operations, had been tracking this since June. Another server in Amsterdam would help, but it would take weeks to arrive, so we also wanted to make deploys cheaper on the hardware we already had.\n\n## What didn’t work\n\nWe tried a few things first. In June, [Donal](https://dev.37signals.com/author/donal/) tested the first two on a single Amsterdam host, comparing it with its neighbors, and they ruled out two likely causes.\n\n**Warming each worker’s database connections.** Puma’s `before_fork` hook clears the master’s connections, and each worker opened its own on its first request. Opening them in `before_worker_boot` instead made no difference. Queries on a freshly booted production host were already under a millisecond, so connections weren’t the problem.\n\n**A synthetic request in each worker.** Next, each worker made a few requests in `before_worker_boot` to an internal controller that touched every model. That ran the middleware, routing and Active Record paths, but it ran them in 63 workers at once — exactly the CPU spike we were trying to avoid. And a request with no real data renders no real views, so most of the app stayed cold.\n\n**Spreading YJIT compilation out.** Delaying YJIT in each worker by a random interval spread the compiling out over a few minutes, but every worker still ran interpreted until its delay ended. The queue didn’t change.\n\n**Reforking from a warm worker.** This is what Shopify’s [Pitchfork](https://github.com/Shopify/pitchfork) does: let one worker serve traffic until it’s warm, then fork the others from it. Puma has an experimental version called `fork_worker`, and on beta it worked — the reforked workers were warm after three to five requests, where fresh ones took up to 30 seconds. But with `fork_worker` the template is worker 0, and it keeps serving requests. If it exits, the workers waiting to be forked never start ([puma/puma#3596](https://github.com/puma/puma/issues/3596)). If it gets no traffic, the refork never happens, which is what we saw on beta. Instacart have a `mold_worker` patch that promotes a warm worker to a template that stops serving, but it isn’t in a Puma release. We have a branch of it, and we may come back to it.\n\nThat last experiment did show us where the fix was, though. Everything a warm worker has that a cold one lacks is in its memory, and `fork` copies memory. The master already has the app loaded. It just never runs it.\n\n## Run the requests in the master\n\nSo now, before the master binds its socket and forks, it makes the app’s own requests, in-process, the way a signed-in user would.\n\nRack has a hook for exactly this. [`Rack::Builder#warmup`](https://rubydoc.info/gems/rack/Rack/Builder#warmup-instance_method) takes a block that’s called once with the built app, before the server starts. `rails server` builds the app from `config.ru`, so the change to boot is one line:\n\n```ruby\nrequire_relative \"config/environment\"\n\nwarmup { WarmUp.configured.run } if ENV[\"WARM_UP\"]\n\nrun Rails.application\n```\n\nWith `preload_app!` this runs in the master, and the workers inherit whatever it did. Puma binds its socket after the app is built, so until the warm-up finishes the health check’s connection is refused and kamal-proxy keeps retrying. No request reaches a worker that hasn’t been warmed.\n\nThe warm-up has three steps. After precompiling the views, it gives the page requests and schema loading a shared 20-second budget, checked before each page or model.\n\n### 1\\. Precompile the views\n\n[`actionview_precompiler`](https://github.com/jhawthorn/actionview_precompiler) reads every template for its `render` calls and compiles each one with the locals it’s passed. For us that’s 1,394 templates in about two seconds. A first request to a project page then compiles 2 templates instead of 44.\n\n### 2\\. Request the pages, signed in\n\nA small browser class makes the requests through `Rack::MockRequest`, with the two cookies a real sign-in sets, then goes back for each page’s lazy Turbo frames:\n\n```ruby\nclass WarmUp::Browser\n  def initialize(signed_in_as:)\n    @client = Rack::MockRequest.new(Rails.application)\n    @headers = {\n      \"HTTP_USER_AGENT\" => \"Basecamp warm-up\",\n      \"HTTP_COOKIE\" => cookie_for(signed_in_as),\n      \"bc3.warm_up\" => true\n    }\n  end\n\n  def visit(path)\n    page = get(path)\n    frames_in(page).each { |id, src| get(src, \"HTTP_TURBO_FRAME\" => id) }\n  end\n\n  private\n    def get(path, headers = {})\n      @client.get(\"https://#{host}#{path}\", @headers.merge(headers))\n    end\n\n    def frames_in(page)\n      Nokogiri::HTML5(page.body).css(\"turbo-frame[src]\").map { |frame| [ frame[\"id\"], frame[\"src\"] ] }\n    end\nend\n```\n\n**The requests are signed in.** The user is a monitoring account we already use for automated checks, and the pages are its own project, Campfire, to-dos, documents and messages. Public pages weren’t enough: after warming up with signed-out pages only, the first signed-in request to the projects page still took 131 ms, because authentication, the signed-in controllers and their views had never run. With signed-in pages it took 40 ms. `cookie_for` writes the same signed cookie the sign-in controller does, using the app’s own cookie jar, so there’s no API token and no secret to store.\n\n**The frames are followed.** The busiest HTML requests in production aren’t pages at all but Turbo frames — the sidebar badge, the inbox, the navigation menus. The browser parses each page and requests its `<turbo-frame src>` URLs with the `Turbo-Frame` header, so those controllers and views get warmed too. Our first four pages turned into 60 requests.\n\n**The requests are excluded from rate limiting.** They are internal, so they do not count against the rate limits that apply to real visitors.\n\n### 3\\. Load the rest of the schema\n\nThe page requests load the schema for the models they touch. The last step loads the rest, from the read replica:\n\n```ruby\nApplicationRecord.reading do\n  models.lazy.take_while { time_left? }.each { |model| model.load_schema if model.table_exists? }\nend\n```\n\nThe step checks 261 models and loads any schema information still missing. Those database round trips add up when the primary is far away: outside a request, Active Record uses the writing role, and from a host a long way from the primary each round trip is tens of milliseconds. Reading from the local replica brings the step down from about 20 seconds to 3.5. The pages go first because they load most of the schema anyway. If the time budget runs out, the step stops, logs how many models it got through, and the workers load the rest on first use like they always did.\n\nRails can also load the schema from a dumped cache file at boot (`bin/rails db:schema:cache:dump`), which would make this step unnecessary. We don’t ship one in our image yet, because the dump needs a database to read from at build time, and we have several databases to cover. It’s on the list.\n\n## What to close before the fork\n\nRunning requests in the master opens things the master never opened before, and every worker inherits them. Two processes writing to the same socket will corrupt each other’s traffic, so you need to know what’s open before you fork.\n\nThe way to find out is to list the master’s open file descriptors — `ls -l /proc/<pid>/fd` — before and after a warm-up, in an environment set up like production. Development wasn’t enough for us: it stores files on disk, so our S3 connections only showed up in production. Then, for each thing that’s open, check how its library handles a fork. We found three kinds:\n\n- **Already handled.** Plenty of libraries detect a fork on their own, either by recording the PID they connected from and reconnecting in the child, by opening per-process files, or by resetting their thread pools. Redis clients, metrics libraries and concurrency libraries tend to be in this group. Check, but you probably don’t need to do anything.\n- **Already closed.** Database connections are the classic one, and most Puma configs already clear them in `before_fork`. Anything else that’s opened per process — we have a SQLite cache the workers open on boot — needs closing when the warm-up finishes.\n- **Needs a new step.** HTTP clients with keep-alive connections are the ones to look for: cloud SDKs with connection pools, tracing exporters, error reporters. They usually have no fork handling at all. We empty the aws-sdk connection pools in `before_fork`, and we run the warm-up untraced so the OpenTelemetry exporter never opens its connection to Tempo in the first place.\n\nOnce that’s done, `before_fork` finishes with `Process.warmup`, which Ruby 3.3 added for this purpose: a major GC, a heap compaction, and every surviving object promoted to the old generation, so the memory pages the workers share change as little as possible afterwards.\n\n## Choosing the pages\n\nThe first list was the four pages that ran the busiest requests on beta. Once the warm-up was live, production showed us which endpoints were still cold. For one deploy, we compared each endpoint’s mean duration in the six minutes after kamal-proxy moved traffic to the new container with the same endpoint an hour later, then multiplied the difference by the number of requests in those six minutes. That gives the extra time each endpoint cost us because it was cold:\n\n| Endpoint            | Cold   | Warm   | Requests in 6 min | Extra seconds |\n| ------------------- | ------ | ------ | ----------------- | ------------- |\n| Campfire            | 246 ms | 70 ms  | 6,490             | 1,140         |\n| Projects (JSON API) | 84 ms  | 50 ms  | 22,077            | 771           |\n| Docs & Files        | 262 ms | 177 ms | 4,996             | 421           |\n| To-dos tool         | 205 ms | 113 ms | 4,018             | 371           |\n| To-dos (JSON API)   | 33 ms  | 16 ms  | 18,738            | 320           |\n\nThe pages already in the warm-up showed what to expect: the project page kept a 36 ms gap after a deploy, and the to-do page 10 ms. We’ve proposed adding these five requests, and expect them to add about five to seven seconds to the page step. The two JSON endpoints were a surprise. The warm-up’s page list had no API requests in it, so nothing on the API path had run before the first real request: not the API controllers, and not the Jbuilder templates rendering real records. Precompiling the views covers JSON templates too, but it isn’t a substitute for running the request.\n\n## Results\n\nThe warm-up is on for all 68 web hosts. With the first four pages it took 12 to 16 seconds per host: about 2 seconds to precompile the views, 7 to 9 for the 60 requests, and 3.5 for the schema. Deploys take that much longer per host, and we raised the deploy timeout from 30 to 60 seconds to cover it.\n\nIn Amsterdam, at a peak-hour deploy:\n\n| During deploy                  | Before             | After           |\n| ------------------------------ | ------------------ | --------------- |\n| Peak Puma backlog per host     | 250–2,238 requests | 19–223 requests |\n| Peak kamal-proxy p99           | about 10 s         | 2.4–4.8 s       |\n| Peak CPU time per request      | 201–214 ms         | 88–132 ms       |\n| Peak database time per request | 56–69 ms           | 39–47 ms        |\n\nThe deploy in the middle, with four hosts warmed and four not, shows why every host needed the warm-up. Each warmed host recovered faster on its own: mean request duration peaked at 130 to 173 ms, against 203 to 311 ms on the hosts that weren’t warmed. But the backlog on all eight was about the same, because they were all waiting on the same database.\n\nMemory came down too. The workers now share compiled templates, YJIT code and the schema with the master instead of each building their own copy. On beta, the view precompiler alone took a busy worker’s private memory from 174–202 MB to 119–135 MB. Thirty minutes after the deploy, the web containers used about 39 GB less memory than the previous day’s containers at the same age and traffic. Amsterdam served most of our traffic at the times we tested. In Amsterdam, each new container used about 2 GB less just after traffic moved to it, which lowers the peak while the old and new containers overlap.\n\n## Working with Claude\n\nClaude Code helped throughout. It combed through the per-worker backlogs and per-endpoint timings in [Prometheus](https://dev.37signals.com/kamal-prometheus/) and Loki after each deploy, worked out the cold-versus-warm cost of each endpoint, and prepared the changes and the pull request descriptions with the benchmarks in them. We decided what to try, deployed it and read the results.\n\n## If you do this\n\n**Warm the master before it forks.** Compile common code and templates and load their schema in the master, so workers inherit that work. With `preload_app!`, `Rack::Builder#warmup` runs before the workers start accepting traffic.\n\n**Use the app’s real requests.** Public pages, internal endpoints and synthetic queries warm the paths they run and nothing else. Signed-in requests to real records, frames included, run what production runs.\n\n**Measure the cold penalty per endpoint.** The difference between an endpoint’s cold and warm duration, times its request count after a deploy, ranks the pages worth adding. Ours weren’t the ones we’d have guessed, and two of them were JSON.\n\n**Check what the warm-up leaves open.** List the master’s file descriptors after a warm-up and account for every one before the fork. Two of ours needed changes.\n\n**Set a time budget.** A warm-up that runs long on one slow host fails the deploy on that host. Ours gives the page requests and schema loading a shared 20-second budget, checked before each page or model, puts the most valuable pages first, and logs what it skipped.\n\nReforking from a warm worker, as Pitchfork does, solves the same problem continuously rather than once at boot, and it would warm paths no fixed list of pages covers. We may still get there: [our branch](https://github.com/basecamp/puma/pull/1) brings Instacart’s `mold_worker` up to date with Puma’s main branch and fixes the bugs we found in it. But warming the master works with the Puma we already run, took a few days to implement, and substantially reduced the queues after deployment.\n",
  "dateCreated": "2026-10-02T15:44:26.851147349+00:00",
  "date_created": "2026-10-01T17:00:00+00:00",
  "date_modified": "2026-10-01T17:00:00+00:00",
  "date_published": "2026-10-01T17:00:00+00:00",
  "description": "How we cut Basecamp 5’s post-deploy request queues by running signed-in requests through the Rails app in the Puma master, before it forks its workers.",
  "external_url": "https://dev.37signals.com/warming-up-the-puma-master-before-it-forks/",
  "git": {
    "commit": "ebb0941c5c06a2ab2377342d607535262a3e969e",
    "path": "items/dev-37signals-com/2026/10/2026-10-01-warming-up-the-puma-master-before-it-forks.md",
    "permalink": "https://github.com/aymericbeaumet/aggr-instance/blob/ebb0941c5c06a2ab2377342d607535262a3e969e/items/dev-37signals-com/2026/10/2026-10-01-warming-up-the-puma-master-before-it-forks.md"
  },
  "id": "urn:aggr:item:4ceb124f052286ae36fc473e12150afa38f6937b",
  "isBasedOn": {
    "@id": "https://dev.37signals.com/warming-up-the-puma-master-before-it-forks/"
  },
  "mainEntity": {
    "@id": "https://dev.37signals.com/warming-up-the-puma-master-before-it-forks/",
    "@type": "CreativeWork",
    "archivedAt": {
      "@id": "https://aggr.aymericbeaumet.com/items/dev-37signals-com/2026-10-01-warming-up-the-puma-master-before-it-forks/#webpage"
    },
    "author": [
      {
        "@type": "Person",
        "name": "Lewis Buckley"
      }
    ],
    "dateModified": "2026-10-01T17:00:00+00:00",
    "datePublished": "2026-10-01T17:00:00+00:00",
    "headline": "Warming up the Puma master before it forks",
    "timeRequired": "PT14M",
    "url": "https://dev.37signals.com/warming-up-the-puma-master-before-it-forks/",
    "wordCount": 3041
  },
  "name": "Archived snapshot: Warming up the Puma master before it forks",
  "preview": null,
  "reading_minutes": 14,
  "replicated_at": null,
  "source": "dev-37signals-com",
  "source_name": "37signals Dev",
  "summary": "How we cut Basecamp 5’s post-deploy request queues by running signed-in requests through the Rails app in the Puma master, before it forks its workers.",
  "tags": [],
  "temporalCoverage": "2026-10-02T15:44:26.851147349+00:00",
  "title": "Warming up the Puma master before it forks",
  "url": "https://aggr.aymericbeaumet.com/items/dev-37signals-com/2026-10-01-warming-up-the-puma-master-before-it-forks/",
  "word_count": 3041
}