All articles
12 September 2026
8 min read

Three measurements before you refactor a single line

By Tim Rutte, Cloud & Software ArchitectTopicBackend & Platforms

A folding rule, a steel ruler and a blue stopwatch on a cleared surface before a renovation.

"We cleaned that module up, it is much faster now." I hear that sentence regularly, and in most cases it is not backed by anything. It is not wrong either. It is simply unverifiable, because nobody measured beforehand what it used to be.

That is more than a formality. Without a baseline, two things happen that make a modernization expensive. Improvements cannot be demonstrated, so the next round cannot be justified. And regressions go unnoticed, because the application still runs, only a little more sluggishly than before.

This article describes which three measurements have to exist before the first rebuild, how to get them in a legacy system without specialist tooling, and which measurements are not worth taking in this context.

Why you do not start without numbers

A rebuild on a running system is a bet: it is meant to be better afterwards than before. Without a starting value that bet cannot be settled, and that has three practical consequences.

Success stays an assertion. At the next budget question, effort stands against feeling. That is exactly the comparison a technical case loses.

Regression goes undetected. A rebuild that moves response time from 180 to 260 milliseconds produces no error and no alert. It produces complaints, three weeks later, that nobody connects to the rebuild any more.

The order stays a matter of taste. Without numbers, cleaning happens where the code is most annoying. With numbers, it happens where it pays most, and those are rarely the same places.

The good news: no monitoring platform is required. For the three measurements below, what a legacy system already produces is enough.

The three measurements, and why not more

Three numbers carry the decision. Every further one costs time to set up and to read without sharpening the view.

One: response time at the outer edge, as a percentile. Not inside a single function, but where the user waits: at the web server. And not as an average, more on that below.

Two: error rate, split into expected and unexpected. A 404 on an old URL is not an incident. A 500 is. Counted together, the signal disappears into the noise, and that is the state of most legacy systems.

Three: saturation of the one resource that runs out first. In PHP applications that is almost always one of three: free worker processes, database connections, or disk I/O. Which one it is, the team usually knows, and if not, the question itself is already a finding.

Deliberately not included: CPU and memory utilization. Both are useful for investigation and poor indicators of health, because they can be high without anything being short, and low while everything is waiting.

Getting the numbers without instrumenting anything

The first place to look is not the application but the web server. It has been logging for years, usually just without the timing.

# nginx: record response times, one line of configuration
log_format timings '$remote_addr $request_method $uri '
                   'status=$status took=$request_time '
                   'upstream=$upstream_response_time';
access_log /var/log/nginx/access.log timings;

The distinction between request_time and upstream_response_time is the most useful single piece of information in that line: the first includes the time the user waits, the second only the time the application needs. Where the two are far apart, the problem is in the network or with slow clients, and every optimization in the code goes nowhere.

For the analysis, the command line is enough. This call gives per-path counts and is all that is needed for the first week:

awk '{
  split($0, f, "took="); split(f[2], g, " ");
  n[$3]++; d[$3] = d[$3] " " g[1];
} END { for (p in n) print n[p], p }' /var/log/nginx/access.log \
  | sort -rn | head -20

The same goes for the database: the slow query log exists in every MySQL installation and is switched off in most. Turned on with a threshold of half a second it costs almost nothing and produces, within a day, the list of queries that actually consume time.

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;
SET GLOBAL log_queries_not_using_indexes = 'ON';
-- Then: mysqldumpslow -s t /var/log/mysql/slow.log | head -20

The third number, saturation, is already available somewhere too: the PHP-FPM status endpoint reports queued requests and busy worker processes, and those two values are the measurement.

The average is the wrong number

The most common measurement in legacy systems is average response time, and it is the only one I consider harmful, because it produces a reassuring result.

An example with invented but typical numbers: ninety per cent of requests are an image lookup and take 20 milliseconds. Ten per cent are the search and take 2 seconds. The average lands at 218 milliseconds and looks respectable. Every tenth user waits two seconds.

So measurement happens in percentiles. The 50th percentile is the typical case, the 95th the bad one, the 99th the one that turns up as a complaint. If you can only write down one number, take the 95th percentile.

Just as important: split by path. A single figure across all requests does not reveal that the home page is fast and the checkout is slow. Five to ten paths are enough, chosen by frequency and by importance to the business.

The baseline takes a week

One hour of measurement is not a baseline, it is a sample. Systems have rhythms: Monday morning looks different from Friday evening, the last day of the month different from the rest, and in many companies a nightly job crowds everything else out.

A week is the minimum that contains those rhythms once in full. The result gets written down somewhere still findable in six months, with a date and with the version that was measured.

Baseline 2026-10-06, build a3f91c, week of 5-11 Oct

Path                     Requests/day   p50     p95     p99
GET  /product/{id}            412,000    41ms   180ms   850ms
POST /basket/add               38,000    95ms   410ms  1,900ms
GET  /search                   21,000   310ms  2,100ms  4,800ms
POST /order                     4,100   520ms  1,400ms  3,100ms

Errors: 5xx 0.08 %   4xx 2.1 % (90 % of them old image URLs)
Saturation: PHP-FPM workers peak 42 of 50, daily 11:00-13:00

That half page is the basis for every later statement. It costs an hour of work and a week of waiting, and it is why the next budget conversation goes differently.

Turning the three measurements into a permanent view that also carries across service boundaries leads through Introducing OpenTelemetry in PHP and Go.

One measurement, one change

With the baseline in place, one rule applies that sounds simple and is hard to keep in practice: between two measurements there is exactly one change.

Anybody who spends a week cleaning up, adds an index, puts in a cache and then measures knows at the end that it got better, but not what did it. Next time all three get done, because nobody knows which one worked. That is exactly how caches appear that achieve nothing and get maintained anyway.

In practice that does not mean every commit has to ship on its own. It means changes aimed at the measurements ship on their own, with enough time between them to see the difference. On a weekly rhythm that is often simply two days.

And it means accepting regressions when they appear. A change that raises the 95th percentile by thirty per cent gets rolled back, even if the code is nicer afterwards.

Numbers say something got slower, not that it got wrong. For that you need Testing legacy code when there are no tests.

What you do not measure in this context

Finally, three measurements that get set up regularly in modernization projects and do not improve the decision.

Code metrics. Cyclomatic complexity, coupling, lines per class. They describe the code, not the experience. A module with bad numbers that nobody has touched in four years costs nothing. The better approximation for "where does it hurt" is in the version history: the files that change most often.

Test coverage as a progress bar. Useful as a tool, harmful as a target. Steer by the number and you get tests for trivial accessors, because those are cheap.

Anything nobody reads. A dashboard nobody has opened in six months costs maintenance and delivers nothing. The three measurements above belong somewhere the team already looks, and the rest gets set up when a concrete question calls for it.

If the baseline is meant to become a permanent view of the system, that is the point where a proper introduction pays: how I introduce observability into an existing system is on its own page. For the first rebuild, the web server log is enough.

This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.