AB.
← All insights
11 min read

Building my website, part 3: Measure before you optimize.

Docker was finally running, but local development was slow. I was ready to blame Docker. Measuring showed three separate problems instead, each with its own fix.

TL;DR: my local setup was slow, and I was ready to blame Docker. Measuring each piece turned up three separate problems instead:

  1. Vite was watching 10,000 files it didn't need.

  2. Pages were slowed down by a slow file share and by trips over the internet for bookkeeping.

  3. Every restart reinstalled every package from scratch.

In part 2 I finally got Docker Desktop running. The next step was starting the site itself:

docker compose up -d --build

That builds the images if needed and starts two containers:

  • app: the Laravel site itself, served by FrankenPHP at localhost:8000

  • vite: the frontend dev server, which rebuilds my React code and reloads the page whenever I save a file, at localhost:5173

Both were up. Both were slow.

The vite container alone was using about 47% of my CPU while doing nothing, and pages were slow to load. It would have been easy to decide "Docker on Windows is slow" and live with it. Instead, I measured each piece before changing anything, running commands with help from AI assistants (Gemini and Claude Code).

Problem one: Vite was watching 10,000 files it didn't need

To understand this one, you need to know how my project gets into the container. The code lives on my Windows drive, in C:\Users\.... Docker bind-mounts that folder into the Linux container, which means the container sees my Windows folder directly instead of getting its own copy. That's what lets me edit a file in my IDE and see the change straight away.

The catch is how Vite notices that I've saved a file. On a normal machine, the operating system sends a notification whenever a file changes. Those notifications don't cross from Windows into the Linux container. So Vite has to fall back to polling: checking every file, over and over, to see if anything has changed.

By default it polled everything, constantly, including the vendor/ folder, which holds about 10,000 PHP files from Laravel and its packages. Vite never needs any of them. It only cares about my frontend code.

The fix was two settings in vite.config.js:

watch: {
    usePolling: true,
    interval: 1000,
    binaryInterval: 3000,
    ignored: [
        '**/vendor/**',
        '**/storage/**',
        '**/bootstrap/cache/**',
        '**/public/build/**',
        '**/database/**',
        '**/tests/**',
    ],
},
  • usePolling: true: poll, because notifications won't arrive.

  • interval: 1000: check once a second. A second is still faster than I can switch from my editor to the browser.

  • binaryInterval: 3000: check binary files like images less often still.

  • ignored: don't watch backend-only folders at all.

I only apply these settings when Vite runs inside Docker. Outside Docker, file notifications work normally and none of this is needed.

~47% → ~2.5%
vite container CPU

580 MB → 74 MB
vite container memory

Problem two: pages were slow, so I measured first

With Vite calmed down, pages were still slow. This is where it would have been easy to start changing settings at random. Instead I measured, and two results stood out:

21.6 s vs near-instant
checking 9,925 vendor/ files through the Windows file share vs. the same check on files stored inside Docker

~1,400 ms + ~120 ms
connecting to the database for the first query of a request, then every query after that

They point at two different problems.

(a) The Windows file share is slow

21.6 seconds for 9,925 files works out to about 2 milliseconds per file. That doesn't sound like much, but PHP touches hundreds of files on every request, and every one of them has to cross the boundary between Windows and Linux.

PHP has a built-in cache called OPcache that hides most of this. The first time PHP runs a file, it compiles it and keeps the compiled version in memory, so later requests don't read the file again. In development, though, OPcache also checks whether each file has changed, so my edits show up. My setup was doing that check on every single request, for every file, across that slow boundary.

The fix was one setting, passed in through docker-compose.yml:

PHP_OPCACHE_REVALIDATE_FREQ: "1"

Now PHP re-checks a file at most once a second, instead of on every request. Production doesn't check at all, because the code inside a deployed image never changes.

This is only a partial fix. The real one is to move the project into the WSL filesystem, so the files live on the Linux side and never cross the boundary. That's expected to be around 100 times faster for file access, but I haven't done it yet, so I can't tell you whether it lives up to that. It's saved for later.

(b) Sessions and cache were stored in a remote database

The other big number was the database: about 1.4 seconds to connect, and 120 ms for every query after that. My database is hosted on NeonDB, a cloud Postgres service. There was no deep reason behind that choice: I just wanted to try it. But it means my database doesn't run on my machine, so every query is a round trip over the internet.

That's expected for real data. What I didn't expect was that some of those trips had nothing to do with the page I was loading:

  • A session is how Laravel remembers a visitor between page loads, for example that you're logged in. It's read at the start of every request and saved at the end.

  • The cache holds short-lived data, like how many times someone has tried to log in.

Both were set to use the database. So every page load was making extra trips across the internet just for bookkeeping.

For local development, I switched both to plain files in docker-compose.yml:

CACHE_STORE: file
SESSION_DRIVER: file

Laravel now stores them in storage/framework/, on the same machine.

Were files the right choice?

Files were the quickest fix, but they come with trade-offs.

What's good about files

  • Nothing new to run. It's a two-line change, with no extra service to install or keep running.

  • Far fewer slow trips. A session is one small file on my own machine instead of a database query over the internet.

  • No setup. Laravel creates the files itself.

What's not so good

  • They're still on the slow file share. storage/framework/ is inside my project folder, so it's bind-mounted from Windows like everything else. A session read and write is only a couple of files per request, which is much cheaper than a database trip, but it isn't free.

  • Local no longer matches production. Production keeps sessions in the database. If something ever broke only with database sessions, I wouldn't see it on my machine.

  • It only fixed the bookkeeping. The page's real queries, like loading my latest Insights, still go to NeonDB.

  • Old files pile up. Laravel only cleans out expired session files now and then: by default, on 2 requests in every 100.

The other options

  • A local Postgres database in its own container. This is the better fix. Sessions and cache could stay in the database, just like production, and every query would stay on my machine, not just the bookkeeping. The cost is one more container, and a local database starts empty, so I'd need some test data.

  • Redis, a fast in-memory store, in a container. It's quick and handles both, but it's another service that production doesn't use.

  • The array driver, which keeps everything in memory and forgets it when the request ends. You can't stay logged in, so it's no good for browsing the site, but it's exactly what my tests use.

For now, files do the job. A local Postgres container is on my list, and when I get to it, sessions and cache can go back to matching production.

Why production still uses the database

If files are so much faster, why not use them everywhere? Because production is a different situation:

  1. Distance. A hosted site can run in the same region as its database, so a round trip takes a few milliseconds instead of 120.

  2. Redeploys. Files inside a container disappear when it's replaced with a new version. Session files going with them would log everyone out on every deploy.

  3. More than one container. If the site ever runs on several containers, they can't see each other's files, but they can all share one database.

The right setting depends on where the code is running. Local development and production want different things, and that's fine.

Problem three: every restart reinstalled everything

This one wasn't in my original list, but it was slowing me down every day. Each time the vite container started, it ran npm ci. That command deletes the node_modules folder and reinstalls every package from scratch. It also threw away Vite's pre-bundled cache, so the first page load after each restart was slow too.

Was my original setup wrong?

Not really. It used the right command in the wrong place. This is how the vite container was first set up in docker-compose.yml:

command: sh -c "npm ci --legacy-peer-deps && npm run dev"
volumes:
  - .:/app
  - node_modules:/app/node_modules

npm ci is built for CI servers and brand-new machines. It always deletes node_modules and installs exactly what package-lock.json lists, so every build starts from the same clean state. On a machine that starts empty every time, that's exactly what you want.

My container didn't start empty, though. Look at the last line: node_modules has its own named volume, which is storage that Docker manages and keeps between restarts. There are two good reasons to set it up that way:

  • Some packages include native code. The Linux versions the container needs aren't the same as the Windows versions on my machine, so the two shouldn't share a folder.

  • A node_modules folder holds thousands of small files, and they'd otherwise all go through the slow Windows file share from Problem two.

So the setup was carefully keeping my packages between restarts, and then npm ci deleted them every time the container started.

Nothing was broken, and the install was always reliable. If I'd only started the container now and then, I'd barely have noticed. But a development container gets restarted many times a day, and 10–15 seconds each time adds up.

The fix

Reinstalling only makes sense when the list of packages has actually changed. That list is recorded in package-lock.json, so the container now remembers a fingerprint (a hash) of that file and only reinstalls when it changes:

sha1sum -c -s node_modules/.docker-lock.sha1 2>/dev/null \
  || (npm ci --legacy-peer-deps && sha1sum package-lock.json > node_modules/.docker-lock.sha1)
npm run dev

Reading it piece by piece:

  • sha1sum -c -s node_modules/.docker-lock.sha1: check whether package-lock.json still matches the fingerprint saved last time. -s keeps it quiet.

  • ||: only if that check fails (the file changed, or there's no fingerprint yet), run what comes next.

  • npm ci ... && sha1sum package-lock.json > ...: reinstall, and if that worked, save a new fingerprint.

  • npm run dev: start Vite either way.

It still uses npm ci, so whenever the packages do change, I get the same clean, exact install as before. It just doesn't run when nothing has changed.

I also gave npm's download cache its own Docker volume, so when a reinstall is needed, packages come from disk instead of the internet. The same idea went into the Dockerfile: BuildKit cache mounts (RUN --mount=type=cache,...) keep Composer and npm downloads between image builds.

10–15 s → ~0.3 s
time until Vite is ready after a restart

What I took away from this

  1. "It's slow" isn't a diagnosis. I had one symptom and three causes: file-watching, the file share and the database. None of them would have been fixed by a single "make Docker faster" change.

  2. Measure the pieces, not the whole. A slow homepage told me nothing. Timing file access and database queries separately told me exactly where to look.

  3. Defaults are built for the common case. Polling every file, checking OPcache on every request and storing sessions in the database are all reasonable defaults. They just didn't fit a Windows machine talking to a database over the internet.

  4. Local and production can disagree, and should. Files for sessions are right on my machine and wrong in production. Picking per environment beats picking once.


What's next

Local development was faster, if not finished. The next step was getting code from my machine to the internet safely, so I started on a deployment pipeline. I built the part that publishes the site first, and then realized nothing stopped a broken commit from being published.

Part 4: CI before CD: a pipeline that can't ship broken code