vendor/autoload.php is not free

46 milliseconds. That is how long the bootstrap of one large application took before the framework even started, in a trace I pulled this week. A good share of it was autoloading. require vendor/autoload.php looks like a constant of nature. With plain PSR-4 rules it is a loop: every class load walks the prefixes and asks the filesystem whether a file exists. A few thousand classes on a cold request, and the loop becomes a number you can see in a flame graph. ...

August 20, 2025 · 2 min · Murat Useinov

One TTL does not fit the whole page

“Hello, Ivan” in the header. That is what broke the full-page cache on one project: either the whole page became uncacheable, or Ivan’s greeting was served to strangers. We managed both, in the same month. Full-page cache behind a reverse proxy is a great first step. It dies on the first personalized element. The way out is to stop thinking of the page as one object. It is regions with different speeds. The header with the user name changes per user. Navigation changes per deploy. The product list changes every few minutes. Prices and stock change every few seconds. Give all of that one TTL and you must pick the minimum, which means almost no cache at all. ...

June 16, 2025 · 2 min · Murat Useinov

The fastest request never reaches PHP

One header, and the FPM load graph fell off a cliff. Cache-Control: public, s-maxage=60 A public catalog page on one project renders the same HTML for every anonymous visitor. Same queries, same JSON from the search service, same template, thousands of times per hour. We profiled it, we tuned it, and only then asked the obvious question: why is PHP involved in the second request at all. HTTP had the answer before my career started. public says a shared cache may store the response. s-maxage gives the CDN or reverse proxy its own lifetime, separate from browser max-age. With Varnish in front, the request path splits in two. MISS: full stack, FPM, database, sixty milliseconds. HIT: the proxy answers from memory, the PHP process never starts, the database never hears about it. Nothing in the application got faster. There was simply less application running. ...

December 3, 2024 · 2 min · Murat Useinov

Pick the library with a profiler, not a chart

Two hundred thousand ops per second against eighty. That was the bar chart, and the team was ready to pick a serializer from it. I asked for one day. The day went like this. Take a representative workload. Not a synthetic three-field object, but a hundred real payloads from production logs, with the nested structures, the nullable mess, the one field that is sometimes a list and sometimes a map. Anonymize them, save as fixtures. This step is the whole method. The bar chart was measured on someone else’s data with someone else’s configuration, and a serializer is exactly the kind of code whose cost depends on the shape of the input. ...

August 7, 2024 · 2 min · Murat Useinov

A faster sprintf will not fix your p99

Three times faster. That is the number in the benchmark posts about PHP 8.4, where simple sprintf() calls with basic placeholders get turned into plain string operations at compile time. The function call disappears. for ($i = 0; $i < 1000000; $i++) { $s = sprintf('%s-%d', $prefix, $i); } I like this work. I do not like how people read it. A microbenchmark measures one function in a loop, alone, with a warm cache and nothing else to do. Now profile a real request from the same application. In every backend I have profiled the picture has the same shape: most of the wall time is waiting. SQL queries, Redis round trips, an HTTP call to some partner API. The CPU spent inside PHP is a modest slice, and sprintf inside that slice is a rounding error. Three times faster rounding error is still a rounding error. ...

June 18, 2024 · 2 min · Murat Useinov

Fast query, slow endpoint

The profiler said the query takes 20 ms. The export endpoint took 900 ms and a quarter of a gigabyte of memory. The missing 880 ms was Doctrine turning five thousand rows into five thousand entities. Hydration is not free. For every row the ORM builds an object, fills properties through reflection, registers it in the unit of work, creates proxies for relations. Per row it is nothing. Times five thousand, it is the endpoint. The profiler shows SQL because SQL is easy to show. ...

August 23, 2022 · 2 min · Murat Useinov

PostgreSQL 14 and the price of a connection

A few hundred connections, mostly idle, and the whole server gets slower. That was Postgres for as long as I remember it: a process per connection, and past some count the snapshot bookkeeping starts to eat everything. PostgreSQL 14 is released today. I had the RC on a test box for a week, so a few notes from a backend seat. The headline for me is that this internal work got a serious rewrite. On our connection-heavy profile, many workers, short queries, lots of idle time, the difference is visible without squinting. Idle connections finally cost close to what people always assumed they cost. ...

September 30, 2021 · 2 min · Murat Useinov

PHP 8.0: ignore the JIT, take the rest

JIT on, JIT off, the same Symfony endpoint. Difference within noise. Same for a Laravel endpoint. PHP 8.0 came out yesterday and this was the first thing I checked, because every headline is about the JIT. Not a scandal. A web request spends its time in I/O, in MySQL, in framework code full of method calls the JIT cannot do much with. JIT is for tight numeric loops, and your controller has none. If you compute fractals in PHP, congratulations. The rest of us can leave it off. ...

November 27, 2020 · 2 min · Murat Useinov

Stop building running totals in PHP

Two hundred thousand rows over the wire to compute forty numbers. That was our monthly report on MySQL 5.7: fetch all transactions for the period, loop in PHP, accumulate a running balance, compare each row with the previous one, rank customers by volume. Classic 5.7 shape, because the database could not say “previous row” or “rank within group”. We are finally moving that project to MySQL 8. Two years after GA, which by database standards is reckless haste. The first win had nothing to do with performance. We deleted PHP. ...

October 6, 2020 · 2 min · Murat Useinov

Average response time explains nothing

Average response time: 180 ms. Ticket from support: “the API is slow”. Both true. The average is a diplomat. It offends nobody and tells you nothing. Latency is a distribution. Our 180 ms hides a p50 of 90 ms and a p99 above four seconds. One request in a hundred is terrible, and with thirty requests per page load, most users hit that unlucky one regularly. The people complaining are not imagining things. They live in the tail, and the average never visits there. ...

August 4, 2020 · 2 min · Murat Useinov