# Laravel cold starts on serverless, and how to measure them

What happens when a scaled-to-zero Laravel container gets its first request, what makes that start longer, and when web.min is worth paying for.

## In short

- A cold start is the wait of the first request after a container was scaled to zero, while an instance starts and the app gets ready to answer.
- On Brewless a cold start covers starting the container, caching config, routes and views, and starting FrankenPHP, before the request itself runs.
- Image size, work in the entrypoint, a sleeping serverless database and an empty OPcache all add to the first request, and each can be checked on its own.
- Setting web.min to 1 removes cold starts for the web container, at the cost of paying for one replica all the time.
- Measure cold starts on your own app, after an idle period, rather than trusting anyone's numbers, including ours.

Scale to zero is the reason serverless can be cheap: when nobody visits, nothing runs and nothing is billed for the container. The price is the cold start: the first visitor after a quiet period waits while the app wakes up.

This post explains what happens during that wait for a Laravel app on [Brewless](/), what makes it longer or shorter, and how to decide whether to keep a replica running. It contains no benchmark numbers. A cold start depends on your app, your image and your settings, so the only figure worth having is the one you measure yourself, and the last section shows how.

## What is a cold start?

A cold start is the extra time a request waits when no instance of its container is running. The provider has to start an instance first, and the app has to get ready, before the request is handled. Every request after that on the same instance skips this part.

On Brewless the web container scales to zero when `web.min` is `0`, which is the default. Scaleway starts an instance as soon as a request comes in, so scaled to zero is not downtime. It is a slower first request.

## What happens during a cold start

Each release is one image, built on Brewless's public base image, and it runs on [FrankenPHP](https://frankenphp.dev) in classic mode. When a request arrives at a container with no running instance, roughly this happens:

1. **Scaleway starts an instance** of the container, from the image of the current release.
2. **The entrypoint runs.** The default `docker/entrypoint.sh` caches the configuration, routes and views with `php artisan config:cache`, `route:cache` and `view:cache`. This happens at start, not at build, because your variables only exist in the running container.
3. **FrankenPHP starts** and listens on the port Scaleway gives it.
4. **The request runs.** In classic mode every request starts with a clean state, as with PHP-FPM. The first requests on a fresh instance also fill OPcache, which starts empty.
5. **Anything the request calls has to be ready too.** If it queries a Serverless SQL Database that was asleep, that database has to wake as well.

Steps 2 and 3 are listed in [Server configuration](https://docs.brewless.eu/deployments/server-configuration), together with the default files.

## What makes it longer

| Factor | Why it matters | What you can do |
| --- | --- | --- |
| Image size | A larger image has more to fetch before the instance can start | Keep `node_modules`, test fixtures and build leftovers out of the image |
| Entrypoint work | Everything in `docker/entrypoint.sh` runs before the first answer | Keep it to the three cache commands plus steps that take moments; never migrations |
| Service providers | Every request boots the framework, the first one included | Defer heavy work out of `boot()` and register providers only where needed |
| Empty OPcache | The first requests compile every PHP file they touch | Nothing to configure; it is per instance and fills itself |
| A sleeping database | A Serverless SQL Database that was asleep has to wake too | A managed database stays on; or accept both waits together |
| Processor time | The start work is mostly CPU | More `web.cpu` (or `web.memory`, which Scaleway pairs with it) |

A few of these are already handled. Brewless removes `node_modules` after the frontend build and installs Composer dependencies without dev packages and with an authoritative class map. OPcache is on, with `opcache.validate_timestamps = 0`, because the code in an image never changes.

> [!WARNING]
> Anything you add to `docker/entrypoint.sh` runs on every start, so each second there is a second a visitor waits after a quiet period. Migrations belong in the release commands, which run once per deploy on the release container.

### Why not worker mode?

FrankenPHP's worker mode, and Laravel Octane, boot the app once and keep it in memory. That makes requests on a running instance faster. It does not help a cold start: a container that was scaled to zero still starts from nothing. It also lets state from one request stay for the next, which with a multi-tenant app can show one tenant's data to another. Brewless refuses a build that declares a worker in `docker/Caddyfile` or starts Octane in `docker/entrypoint.sh`.

## What does not wake the container

Not every visit reaches your app. A response bunny.net serves from its cache never reaches the container. Brewless itself never sends requests to your app to measure it, because a health check would keep it awake; usage is counted from Scaleway's figures.

The worker container is a separate case. With jobs on Scaleway Queues, the queue calls the worker when a job arrives and crons call it only when a scheduled task is due. With jobs in the database, a trigger calls it every minute. Those calls start the worker container, not the web container, so they do not take a cold start away from your visitors. They can keep a serverless database awake, though; see [Queues and schedule](https://docs.brewless.eu/running/queues-and-schedule).

## The trade-off of web.min

`web.min` in `brewless.yml` sets how many replicas keep running:

```yaml brewless.yml
environments:
  production:
    web:
      min: 1
      max: 10
  staging:
    web:
      min: 0
      max: 2
```

| | `web.min: 0` | `web.min: 1` |
| --- | --- | --- |
| First request after quiet | Waits for a cold start | Answered by the running replica |
| Container cost while idle | Nothing | One replica, all the time |
| Cold starts under load | When traffic grows past what running replicas take, new replicas start | Same |

One replica takes up to 50 requests at once by default (`web.concurrency`) before another starts. So `min: 1` removes the cold start of the quiet-to-busy moment, but a sudden spike can still start extra replicas, and those start cold too.

The usual choice is `min: 1` on production, where a visitor should not wait, and `min: 0` on staging and development, where a wait does not matter and the saving does. You can also set it with `brewless scale production --min=1`, unless `brewless.yml` sets it; then the file wins. See [Scaling](https://docs.brewless.eu/running/scaling).

## How to measure it yourself

You need two numbers: how long a cold request takes, and how often it happens.

**How long.** Leave the environment alone until the console shows no web replicas running. Then time one request against the environment's [test address](https://docs.brewless.eu/running/domains#the-test-address), which goes straight to the container without bunny.net in between:

```bash
curl -s -o /dev/null -w 'connect %{time_connect}s  first byte %{time_starttransfer}s  total %{time_total}s\n' \
  https://acme-shop-staging.brewless.dev/
```

Run the same command again right after: that is a warm request on the same instance. The difference between the two is your cold start. Do it a few times, on different moments of the day, and use a page that queries the database if your real pages do. Note the result together with the image of that release, so you can compare after a change.

> [!NOTE]
> Measuring keeps the container awake for a while. Wait until it is scaled to zero again before the next cold measurement.

**Where the time goes.** Follow the logs of the web container while you make the cold request:

```bash
brewless logs staging --tail --source=web
```

An entrypoint step that fails, or one that is slow, shows up there before the first request line.

**How often.** The page of an environment shows, for the last 30 days or a period you choose, how long the web container ran, how long it was scaled to zero, and how often it went from no instance to at least one: the number of cold starts. Multiply that by the length you measured and you know how much waiting your visitors did, and whether one running replica is worth its price. [What a Laravel app costs on serverless](/blog/estimate-laravel-serverless-costs) shows how to put a price on that replica.

## Frequently asked questions

### What is a cold start in serverless Laravel?

It is the extra wait of a request that arrives when no instance of the container is running. The provider first starts an instance and the app gets ready before the request is handled. Requests that follow on that instance do not wait for it.

### Does Brewless use Octane or FrankenPHP worker mode to make cold starts shorter?

No. Brewless runs FrankenPHP in classic mode and refuses a build that declares a worker or starts Octane. A scaled-to-zero container starts cold either way, and worker mode would let state leak from one request into the next.

### How do I avoid cold starts on production?

Set web.min to 1 in brewless.yml for that environment, so one replica keeps running. You pay for that replica all the time, so it is usually worth it on production and not on staging.

### Does a request served from the bunny.net cache wake the container?

No. A response bunny.net serves from its own cache never reaches your container. Only requests that go through to the app can start an instance.

### How can I see how often my app had a cold start?

The page of an environment in the Brewless console shows how often the web container went from no instance to at least one, next to how long it ran and how long it was scaled to zero. The figures come from Scaleway's own metrics, counted once an hour.

Source: https://test.brewless.eu/blog/laravel-scale-to-zero-cold-starts
