Under the hood
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 this post
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 in classic mode. When a request arrives at a container with no running instance, roughly this happens:
- Scaleway starts an instance of the container, from the image of the current release.
- The entrypoint runs. The default
docker/entrypoint.shcaches the configuration, routes and views withphp artisan config:cache,route:cacheandview:cache. This happens at start, not at build, because your variables only exist in the running container. - FrankenPHP starts and listens on the port Scaleway gives it.
- 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.
- 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, 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.shruns 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.
The trade-off of web.min
web.min in brewless.yml sets how many replicas keep running:
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.
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, which goes straight to the container without bunny.net in between:
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:
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 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.