Net Time to First Byte (NTTFB): The Metric TTFB Should Have Been

Andrew Baker · February 2026 · andrewbaker.ninja

1 The Problem with TTFB

Time to First Byte has been the go to diagnostic for server responsiveness since the early days of web performance engineering. Google’s own web.dev guidance describes TTFB as measuring the elapsed time between the start of navigation and when the first byte of the response arrives. That measurement captures redirect time, service worker startup, DNS lookup, TCP and TLS negotiation, and the server processing time up to the point the first byte leaves the origin.

The fundamental problem with TTFB is that it mixes together things you can control with things you cannot. Your application code, your database queries, your cache hit rates, your template rendering: those are within your control. The physical distance between the client and your server, the speed of light through fibre, the number of network hops between two points on the internet: those are not. TTFB treats them as one number, which means the metric is often just telling you something you already know.

Consider the reality of most internet services. Not every product is a global hyperscaler with edge nodes on six continents. The vast majority of services are deliberately hosted in a single region because that is where their customers are, where their data residency requirements are met, or where their budget allows. A South African bank runs out of South Africa. A Brazilian logistics platform runs out of Sao Paulo. A Nordic SaaS product runs out of Stockholm. These are conscious, correct architectural decisions.

Now imagine someone in China runs a TTFB test against that South African banking service and gets 650ms. What does that number tell you? It tells you that China is far from South Africa. You already knew that. It tells you nothing about whether the application is fast, whether the database is healthy, whether the caching layer is working, or whether there is any optimisation opportunity at all. The TTFB is dominated by uncontrollable latency (the round trip across the planet) and the signal you actually care about (server processing time) is buried inside it.

This is not a niche problem. At Capitec, our services are built for South African customers on South African infrastructure. When we look at TTFB dashboards that include users connecting from across the continent or beyond, the numbers are noisy and misleading. A user in Nairobi will always have a higher TTFB than a user in Cape Town hitting the same perfectly healthy server. Chasing that number leads to phantom regressions and wasted engineering effort. The metric is measuring geography, not performance.

2 Introducing NTTFB: Net Time to First Byte

NTTFB (Net Time to First Byte) exists to filter out the uncontrollable latency and show you only the part you can do something about.

The idea is straightforward. Before measuring TTFB, measure the baseline network round trip time to the same host using a simple ping. Then subtract it:

NTTFB = TTFB - Ping RTT

Where:

TTFB is the standard Time to First Byte for the HTML document response, and Ping RTT is the ICMP (or TCP) round trip time to the same host, measured immediately before the HTTP requests.

What remains after the subtraction is the time your infrastructure actually spent doing work: routing the request, executing application logic, querying databases, rendering templates, and flushing the first bytes of the response.

The ping RTT represents the absolute minimum time a packet can travel between client and server and back. It is governed by the speed of light, the physical cable route, and the number of network hops. You cannot optimise it (short of moving your server or your user). By subtracting it, you remove the geography from the measurement and isolate the engineering.

This means a developer in Shanghai testing your Johannesburg hosted API gets a meaningful number. The NTTFB might come back as 85ms regardless of the fact that the raw TTFB was 580ms. That 85ms is the real optimisation opportunity. It is the part that will improve if you add a caching layer, optimise a query, or reduce middleware overhead. The other 495ms is the distance between two continents, and no amount of code changes will alter it.

3 Why This Matters

3.1 Focus on What You Can Change

The single most important reason to use NTTFB is that it separates the controllable from the uncontrollable. Every millisecond in your NTTFB is a millisecond you can potentially eliminate through engineering: better queries, smarter caches, leaner middleware, faster template rendering, more efficient serialisation. Every millisecond in the ping RTT is physics, and no deployment is going to change it.

When your monitoring dashboards show NTTFB instead of (or alongside) TTFB, your engineering team stops asking “why is our TTFB high for users in Lagos?” and starts asking “is our server processing time acceptable regardless of where the user is?” The first question has no actionable answer. The second one does.

3.2 Regional Services Are the Norm, Not the Exception

The TTFB metric implicitly assumes that high latency is a problem to be solved, usually by deploying to more regions or adding edge nodes. But for most services, single region hosting is not a deficiency. It is the correct architecture.

A South African bank serves South African customers. A Japanese e-commerce platform serves Japanese shoppers. A German healthcare portal serves German patients. These services are regional by design, by regulation, or by economics. Telling them their TTFB is “poor” because someone tested from the other side of the planet is not useful feedback. It is noise.

NTTFB lets regional services evaluate their performance honestly. If your NTTFB is 80ms, your server is fast. That is true whether the user is 20ms away in the same city or 300ms away on another continent. The metric reveals the optimisation opportunity that actually exists in your stack, rather than punishing you for a geographic reality that is not going to change.

3.3 Isolating Backend Regressions

When NTTFB increases, you know something changed in your stack: a slower database query, a cache miss, a new middleware layer, a garbage collection pause. You are not guessing whether the ISP changed a peering arrangement or a submarine cable is congested. The network component has been removed, so any movement in the number is signal, not noise.

3.4 Honest CDN and Edge Evaluation

Evaluating CDN providers becomes straightforward. If Provider A gives you a lower TTFB but the same NTTFB, the improvement is purely edge proximity: they have a closer point of presence, but they are not serving your content any faster. If Provider B gives you a lower NTTFB, they are genuinely serving your content faster from cache or optimising the origin fetch. NTTFB lets you tell the difference.

3.5 Capacity Planning and SLA Design

NTTFB lets you set server side SLAs that are independent of where your users sit. You can commit to “NTTFB under 150ms at p75” and that number means the same thing whether the request originates in Durban, Nairobi, or Amsterdam. It becomes a universal measure of your backend’s capability rather than a geography dependent number that needs to be interpreted differently for every region.

4 What a Good NTTFB Looks Like

Since we are stripping out network latency, the thresholds are tighter than standard TTFB guidance. These are guidelines for HTML document responses from your origin or edge:

RatingNTTFB (ms)What It Means
Excellent< 50Edge cached or extremely fast origin. You are serving from memory.
Good50 to 150Healthy dynamic application. Database queries are indexed and templates are efficient.
Needs Improvement150 to 400Investigate slow queries, missing caches, or unoptimised middleware.
Poor> 400Serious backend bottleneck. Server side rendering timeout risk. Likely impacts LCP.

For context, the standard TTFB “good” threshold from Google is 800ms, but that includes all network latency. Once you remove the network component, anything above 400ms of pure server processing time is a red flag regardless of what your raw TTFB dashboard shows.

5 The Relationship to TTFB Components

The web.dev TTFB documentation breaks the metric into these phases:

  1. Redirect time
  2. Service worker startup time (if applicable)
  3. DNS lookup
  4. Connection and TLS negotiation
  5. Request processing, up until the first response byte

NTTFB essentially collapses phases 3 and 4 (the network negotiation overhead that correlates with physical distance) and focuses your attention on phase 5 plus any redirect or service worker overhead that is genuinely within your control.

Note that DNS lookup time is partially network dependent and partially configuration dependent (are you using a fast resolver, is the TTL reasonable, is the record cached). NTTFB does not perfectly isolate DNS from server processing, but in practice the ping RTT is a strong proxy for the combined network overhead because DNS resolution to your own domain typically traverses similar network paths.

6 Early Hints and NTTFB

The web.dev article notes that HTTP 103 Early Hints complicates TTFB measurement because the “first byte” might be an early hint rather than the actual document response. This matters for NTTFB too.

If your server sends a 103 response quickly while the full document is still being prepared, the raw TTFB will appear low, but the time to useful content has not actually improved. NTTFB should ideally be calculated against the final document response (the HTTP 200), not the 103. In Chrome, the finalResponseHeadersStart timing entry captures this, and the measurement script below uses curl’s time_starttransfer which measures when the first byte of the response body begins to arrive.

When comparing NTTFB across platforms, be aware that servers using Early Hints will naturally report lower values. Document your measurement methodology and be consistent.

7 Measuring NTTFB: The Script

Below is a complete Bash script (nttfb.sh) that measures NTTFB from the command line. It is designed for lab testing and synthetic monitoring, not real user measurement. It runs on macOS and Linux without modification.

7.1 How the Script Works

The measurement follows three phases:

Phase 1: Ping RTT baseline. The script sends N individual ICMP ping packets to the resolved host (default 5, configurable with -n), one at a time, and records each RTT. It then calculates the average, minimum, and maximum. If ICMP is blocked (common in corporate networks and some cloud providers), it automatically falls back to measuring TCP handshake time via curl, which is a close proxy for network round trip latency.

Phase 2: TTFB measurement. The script sends N HTTP requests to the target URL using curl, capturing time_namelookup (DNS), time_connect (TCP), time_appconnect (TLS), and time_starttransfer (TTFB) for each. A 300ms delay between requests avoids burst throttling from CDNs or rate limiters. It calculates the average, minimum, and maximum TTFB across all samples.

Phase 3: NTTFB calculation. The average ping RTT is subtracted from the average TTFB to produce the NTTFB. The script also computes conservative min/max bounds: NTTFB Min uses the lowest TTFB minus the highest ping (best case server performance under worst case network), and NTTFB Max uses the highest TTFB minus the lowest ping (worst case server under best case network). This gives you the full range of server processing time with network variance accounted for. The result is classified against the guideline thresholds.

7.2 macOS Compatibility Notes

The script avoids GNU specific extensions that are not available on macOS out of the box:

grep: macOS ships with BSD grep which does not support -P (Perl regex) or \K lookbehind. The script uses -oE (extended regex) with sed for extraction instead, which works identically on both platforms.

ping: On Linux, -W takes seconds. On macOS, -W takes milliseconds. The script detects the OS at runtime and passes the correct value (5 seconds on Linux, 5000 milliseconds on macOS).

DNS resolution: macOS does not have getent. The script tries getent first (Linux), then falls back to dig and then host, all of which are available on macOS with the default developer tools or Homebrew.

awk and sed: The script uses only POSIX compatible awk and sed syntax, which is identical across GNU and BSD implementations.

curl: Both macOS and Linux ship with curl. The -w format string variables used (time_namelookup, time_connect, time_appconnect, time_starttransfer, http_code) have been stable across curl versions for over a decade.

7.3 Requirements

The script requires bash (3.2+ which ships with macOS, or any 4.x+ on Linux), curl, ping, awk, sed, and sort. All of these are available by default on macOS and any modern Linux distribution. No Homebrew packages or additional installs are needed.

7.4 Installation

Copy the entire block below and paste it into your terminal. It writes the script to nttfb.sh in the current directory and makes it executable:

cat << 'NTTFB_EOF' > nttfb.sh
#!/usr/bin/env bash
# =============================================================================
#  nttfb.sh - Net Time to First Byte
#
#  Measures true server processing time by subtracting network latency from TTFB.
#  Works on macOS and Linux without modification.
#
#  Method:
#    1. Send N ICMP pings, average the RTT (default N=5)
#    2. Send N curl requests, capture TTFB for each
#    3. NTTFB = avg(TTFB) - avg(Ping RTT)
#
#  Usage:
#    ./nttfb.sh <url>
#    ./nttfb.sh -n 10 <url>
#    ./nttfb.sh -H "Authorization: Bearer TOKEN" <url>
#    ./nttfb.sh -k https://self-signed.example.com
#
#  Guideline NTTFB Thresholds:
#    Excellent      < 50ms     Edge cached / memory served
#    Good           50-150ms   Healthy dynamic application
#    Needs Work     150-400ms  Investigate backend bottlenecks
#    Poor           >= 400ms   Serious server side issue
#
#  Author: Andrew Baker - andrewbaker.ninja
#  License: MIT
# =============================================================================

set -euo pipefail

# ---------------------------------------------------------------------------
#  Force C locale so awk/printf/curl use dot decimal separators (not commas)
#  Without this, locales like de_DE or fr_FR break all arithmetic
# ---------------------------------------------------------------------------
export LC_ALL=C
export LANG=C

# ---------------------------------------------------------------------------
#  Defaults
# ---------------------------------------------------------------------------
SAMPLES=5
TIMEOUT=30
FOLLOW_REDIRECTS="-L"
INSECURE=""
declare -a HEADERS=()
URL=""

# ---------------------------------------------------------------------------
#  Detect OS (for ping flag differences)
# ---------------------------------------------------------------------------
OS_TYPE="linux"
if [[ "$(uname -s)" == "Darwin" ]]; then
    OS_TYPE="darwin"
fi

# ---------------------------------------------------------------------------
#  Colours (disabled when piped)
# ---------------------------------------------------------------------------
if [[ -t 1 ]]; then
    RST="\033[0m"  BLD="\033[1m"  DIM="\033[2m"
    RED="\033[31m" GRN="\033[32m" YLW="\033[33m" BLU="\033[34m" CYN="\033[36m" GRY="\033[90m"
else
    RST="" BLD="" DIM="" RED="" GRN="" YLW="" BLU="" CYN="" GRY=""
fi

# ---------------------------------------------------------------------------
#  Usage
# ---------------------------------------------------------------------------
usage() {
    cat <<'EOF'
Usage: nttfb.sh [options] <url>

Measures Net Time to First Byte by subtracting ping RTT from TTFB.
Takes N ping samples and N TTFB samples, averages both, then subtracts.

Options:
  -n <count>     Number of samples for both ping and TTFB (default: 5)
  -H <header>    Custom HTTP header (repeatable)
  -k             Allow insecure TLS
  -t <seconds>   Timeout (default: 30)
  -h, --help     Show help

Examples:
  ./nttfb.sh https://example.com
  ./nttfb.sh -n 10 https://example.com
  ./nttfb.sh -n 3 -H "Authorization: Bearer abc123" https://api.example.com
  ./nttfb.sh -k https://staging.internal.example.com
EOF
    exit 0
}

# ---------------------------------------------------------------------------
#  Parse Arguments
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
    case "$1" in
        -n)          SAMPLES="$2"; shift 2 ;;
        -H)          HEADERS+=("-H" "$2"); shift 2 ;;
        -k)          INSECURE="-k"; shift ;;
        -t)          TIMEOUT="$2"; shift 2 ;;
        -h|--help)   usage ;;
        -*)          echo "Error: Unknown option: $1" >&2; exit 1 ;;
        *)           URL="$1"; shift ;;
    esac
done

if [[ -z "$URL" ]]; then
    echo "Error: URL is required." >&2
    echo "Usage: nttfb.sh [options] <url>" >&2
    exit 1
fi

if [[ "$SAMPLES" -lt 1 ]] 2>/dev/null; then
    echo "Error: Sample count must be a positive integer." >&2
    exit 1
fi

# ---------------------------------------------------------------------------
#  Helpers
# ---------------------------------------------------------------------------
extract_host() {
    echo "$1" | sed -E 's|^https?://||' | sed -E 's|[:/].*||'
}

resolve_ip() {
    local host="$1" ip=""
    # Linux: getent
    ip=$(getent ahostsv4 "$host" 2>/dev/null | head -1 | awk '{print $1}') || true
    # Fallback: dig
    if [[ -z "$ip" ]]; then
        ip=$(dig +short "$host" A 2>/dev/null | grep -E '^[0-9.]+$' | head -1) || true
    fi
    # Fallback: host
    if [[ -z "$ip" ]]; then
        ip=$(host "$host" 2>/dev/null | awk '/has address/{print $4; exit}') || true
    fi
    echo "$ip"
}

classify() {
    local val="$1"
    awk "BEGIN {
        v = $val + 0
        if (v < 50)       print \"EXCELLENT\"
        else if (v < 150) print \"GOOD\"
        else if (v < 400) print \"NEEDS IMPROVEMENT\"
        else              print \"POOR\"
    }"
}

color_class() {
    case "$1" in
        EXCELLENT)           printf '%b' "${GRN}" ;;
        GOOD)                printf '%b' "${BLU}" ;;
        "NEEDS IMPROVEMENT") printf '%b' "${YLW}" ;;
        POOR)                printf '%b' "${RED}" ;;
    esac
}

# Extract a value from "key=value" formatted string (macOS + Linux safe)
extract_val() {
    local key="$1" input="$2"
    echo "$input" | sed -E "s/.*${key}=([0-9.]+).*/\1/"
}

# ---------------------------------------------------------------------------
#  Resolve host
# ---------------------------------------------------------------------------
HOST=$(extract_host "$URL")
IP=$(resolve_ip "$HOST")

if [[ -z "$IP" ]]; then
    echo "Error: Could not resolve host: $HOST" >&2
    exit 1
fi

# ---------------------------------------------------------------------------
#  Banner
# ---------------------------------------------------------------------------
printf "\n${BLD}=================================================================================${RST}\n"
printf "  ${BLD}NTTFB: Net Time to First Byte${RST}\n"
printf "  Target: ${CYN}%s${RST}\n" "$URL"
printf "  Samples: %d\n" "$SAMPLES"
printf "${BLD}=================================================================================${RST}\n\n"
printf "  Resolving host.............. %s -> ${BLD}%s${RST}\n\n" "$HOST" "$IP"

# ---------------------------------------------------------------------------
#  Phase 1: Ping (N samples)
# ---------------------------------------------------------------------------
printf "  ${BLD}Phase 1: Measuring Ping RTT (%d packets)${RST}\n" "$SAMPLES"
printf "  ${GRY}---------------------------------------------${RST}\n"

declare -a PING_VALUES=()
PING_FAILED=0

for ((i=1; i<=SAMPLES; i++)); do
    RTT=""

    # Send one ping with OS appropriate timeout flag
    if [[ "$OS_TYPE" == "darwin" ]]; then
        PING_OUT=$(ping -c 1 -W 5000 "$HOST" 2>/dev/null) || true
    else
        PING_OUT=$(ping -c 1 -W 5 "$HOST" 2>/dev/null) || true
    fi

    # Extract time= value (works on both macOS and Linux grep)
    if [[ -n "$PING_OUT" ]]; then
        RTT=$(echo "$PING_OUT" | grep -oE 'time=[0-9.]+' | sed 's/time=//' | head -1) || true
    fi

    # TCP fallback if ICMP fails
    if [[ -z "$RTT" ]]; then
        if [[ $PING_FAILED -eq 0 ]]; then
            printf "  ${YLW}ICMP blocked, falling back to TCP handshake RTT${RST}\n"
            PING_FAILED=1
        fi
        TCP_TIME=$(curl -so /dev/null -w '%{time_connect}' \
            --connect-timeout "$TIMEOUT" $INSECURE "$URL" 2>/dev/null) || true
        RTT=$(awk "BEGIN {printf \"%.3f\", ${TCP_TIME:-0} * 1000}")
    fi

    PING_VALUES+=("${RTT:-0}")
    printf "    Ping %2d:  %10.3f ms\n" "$i" "${RTT:-0}"
    sleep 0.1
done

PING_AVG=$(printf '%s\n' "${PING_VALUES[@]}" | awk '{s+=$1} END {printf "%.3f", s/NR}')
PING_MIN=$(printf '%s\n' "${PING_VALUES[@]}" | sort -n | head -1)
PING_MAX=$(printf '%s\n' "${PING_VALUES[@]}" | sort -n | tail -1)

printf "\n    ${BLD}Avg: %10.3f ms${RST}    Min: %.3f ms    Max: %.3f ms\n\n" \
    "$PING_AVG" "$PING_MIN" "$PING_MAX"

# ---------------------------------------------------------------------------
#  Phase 2: TTFB (N samples)
# ---------------------------------------------------------------------------
printf "  ${BLD}Phase 2: Measuring TTFB (%d requests)${RST}\n" "$SAMPLES"
printf "  ${GRY}---------------------------------------------${RST}\n"

declare -a TTFB_VALUES=()
declare -a DNS_VALUES=()
declare -a TCP_VALUES=()
declare -a TLS_VALUES=()
HTTP_CODE=""

for ((i=1; i<=SAMPLES; i++)); do
    TIMINGS=$(curl -so /dev/null \
        -w 'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} code=%{http_code}' \
        --connect-timeout "$TIMEOUT" \
        $FOLLOW_REDIRECTS $INSECURE \
        "${HEADERS[@]+"${HEADERS[@]}"}" \
        "$URL" 2>/dev/null) || true

    DNS_S=$(extract_val "dns" "$TIMINGS")
    TCP_S=$(extract_val "tcp" "$TIMINGS")
    TLS_S=$(extract_val "tls" "$TIMINGS")
    TTFB_S=$(extract_val "ttfb" "$TIMINGS")
    HTTP_CODE=$(echo "$TIMINGS" | sed -E 's/.*code=([0-9]+).*/\1/')

    DNS_MS=$(awk  "BEGIN {printf \"%.3f\", ${DNS_S:-0} * 1000}")
    TCP_MS=$(awk  "BEGIN {printf \"%.3f\", ${TCP_S:-0} * 1000}")
    TLS_MS=$(awk  "BEGIN {printf \"%.3f\", ${TLS_S:-0} * 1000}")
    TTFB_MS=$(awk "BEGIN {printf \"%.3f\", ${TTFB_S:-0} * 1000}")

    TTFB_VALUES+=("$TTFB_MS")
    DNS_VALUES+=("$DNS_MS")
    TCP_VALUES+=("$TCP_MS")
    TLS_VALUES+=("$TLS_MS")

    printf "    Req %2d:  TTFB %10.3f ms    (DNS %.1f  TCP %.1f  TLS %.1f)  HTTP %s\n" \
        "$i" "$TTFB_MS" "$DNS_MS" "$TCP_MS" "$TLS_MS" "$HTTP_CODE"
    sleep 0.3
done

TTFB_AVG=$(printf '%s\n' "${TTFB_VALUES[@]}" | awk '{s+=$1} END {printf "%.3f", s/NR}')
TTFB_MIN=$(printf '%s\n' "${TTFB_VALUES[@]}" | sort -n | head -1)
TTFB_MAX=$(printf '%s\n' "${TTFB_VALUES[@]}" | sort -n | tail -1)

DNS_AVG=$(printf '%s\n' "${DNS_VALUES[@]}" | awk '{s+=$1} END {printf "%.3f", s/NR}')
TCP_AVG=$(printf '%s\n' "${TCP_VALUES[@]}" | awk '{s+=$1} END {printf "%.3f", s/NR}')
TLS_AVG=$(printf '%s\n' "${TLS_VALUES[@]}" | awk '{s+=$1} END {printf "%.3f", s/NR}')

printf "\n    ${BLD}Avg: %10.3f ms${RST}    Min: %.3f ms    Max: %.3f ms\n\n" \
    "$TTFB_AVG" "$TTFB_MIN" "$TTFB_MAX"

# ---------------------------------------------------------------------------
#  Phase 3: Calculate NTTFB
# ---------------------------------------------------------------------------
NTTFB=$(awk "BEGIN {v = $TTFB_AVG - $PING_AVG; printf \"%.3f\", (v < 0 ? 0 : v)}")
NTTFB_MIN=$(awk "BEGIN {v = $TTFB_MIN - $PING_MAX; printf \"%.3f\", (v < 0 ? 0 : v)}")
NTTFB_MAX=$(awk "BEGIN {v = $TTFB_MAX - $PING_MIN; printf \"%.3f\", (v < 0 ? 0 : v)}")

NTTFB_RANGE=$(awk "BEGIN {v = $NTTFB_MAX - $NTTFB_MIN; printf \"%.3f\", (v < 0 ? 0 : v)}")

CLASS=$(classify "$NTTFB")
CLR=$(color_class "$CLASS")

printf "  ${BLD}Phase 3: Results${RST}\n"
printf "  ${GRY}---------------------------------------------${RST}\n\n"

printf "  +------------------------------------------------------------------+\n"
printf "  |                                                                  |\n"
printf "  |  Avg TTFB (raw)     %10.3f ms                                |\n" "$TTFB_AVG"
printf "  |  Avg Ping RTT       %10.3f ms   ${DIM}(subtracted)${RST}                |\n" "$PING_AVG"
printf "  |  --------------------------------                                |\n"
printf "  |  ${BLD}NTTFB              %10.3f ms${RST}   ${CLR}${BLD}[%s]${RST}%*s|\n" \
    "$NTTFB" "$CLASS" $((22 - ${#CLASS})) ""
printf "  |                                                                  |\n"
printf "  |  NTTFB Min          %10.3f ms                                |\n" "$NTTFB_MIN"
printf "  |  NTTFB Max          %10.3f ms                                |\n" "$NTTFB_MAX"
printf "  |  NTTFB Range        %10.3f ms                                |\n" "$NTTFB_RANGE"
printf "  |                                                                  |\n"
printf "  |  TTFB Min           %10.3f ms                                |\n" "$TTFB_MIN"
printf "  |  TTFB Max           %10.3f ms                                |\n" "$TTFB_MAX"
printf "  |  Ping Min           %10.3f ms                                |\n" "$PING_MIN"
printf "  |  Ping Max           %10.3f ms                                |\n" "$PING_MAX"
printf "  |                                                                  |\n"
printf "  +------------------------------------------------------------------+\n\n"

# ---------------------------------------------------------------------------
#  Breakdown
# ---------------------------------------------------------------------------
SERVER_WAIT=$(awk "BEGIN {printf \"%.3f\", $TTFB_AVG - $DNS_AVG - $TCP_AVG - $TLS_AVG}")

printf "  ${BLD}Avg Breakdown:${RST}\n"
printf "    DNS Lookup        %10.3f ms\n" "$DNS_AVG"
printf "    TCP Connect       %10.3f ms\n" "$TCP_AVG"
printf "    TLS Handshake     %10.3f ms\n" "$TLS_AVG"
printf "    Server Wait       %10.3f ms\n" "$SERVER_WAIT"
printf "    ${GRY}Network Overhead  %10.3f ms   (ping RTT, subtracted from total)${RST}\n\n" "$PING_AVG"

# ---------------------------------------------------------------------------
#  Thresholds legend
# ---------------------------------------------------------------------------
printf "  ${BLD}Thresholds:${RST}\n"
printf "    ${GRN}Excellent${RST}      < 50ms     Edge cached or memory served\n"
printf "    ${BLU}Good${RST}           50-150ms   Healthy dynamic application\n"
printf "    ${YLW}Needs Work${RST}     150-400ms  Investigate backend bottlenecks\n"
printf "    ${RED}Poor${RST}           >= 400ms   Serious server side issue\n"

printf "\n${BLD}=================================================================================${RST}\n\n"
NTTFB_EOF
chmod +x nttfb.sh

7.5 Usage

# Default: 5 ping samples, 5 TTFB samples
./nttfb.sh https://andrewbaker.ninja

# Custom sample count: 10 pings, 10 TTFB requests
./nttfb.sh -n 10 https://your-site.com

# Quick check: 3 samples
./nttfb.sh -n 3 https://your-site.com

# With auth header
./nttfb.sh -n 5 -H "Authorization: Bearer YOUR_TOKEN" https://api.example.com

# Insecure TLS (self signed certs)
./nttfb.sh -k https://staging.internal.example.com

The -n flag controls how many samples are taken for both the ping and TTFB phases. The default is 5, which gives a good balance between accuracy and speed. Use -n 3 for a quick check, -n 10 or higher when you need statistical confidence for a report.

8 Example Output

8.1 Single Run Against a CDN Hosted Site (Johannesburg to Cape Town Edge)

=================================================================================
  NTTFB: Net Time to First Byte
  Target: https://www.capitecbank.co.za
  Samples: 5
=================================================================================

  Resolving host.............. www.capitecbank.co.za -> 104.18.25.47

  Phase 1: Measuring Ping RTT (5 packets)
  ---------------------------------------------
    Ping  1:      17.834 ms
    Ping  2:      18.221 ms
    Ping  3:      19.105 ms
    Ping  4:      17.956 ms
    Ping  5:      18.044 ms

    Avg:     18.232 ms    Min: 17.834 ms    Max: 19.105 ms

  Phase 2: Measuring TTFB (5 requests)
  ---------------------------------------------
    Req  1:  TTFB    251.443 ms    (DNS 11.2  TCP 18.9  TLS 37.8)  HTTP 200
    Req  2:  TTFB    243.891 ms    (DNS  1.1  TCP 18.4  TLS 36.9)  HTTP 200
    Req  3:  TTFB    248.207 ms    (DNS  0.9  TCP 18.7  TLS 37.2)  HTTP 200
    Req  4:  TTFB    256.334 ms    (DNS  0.8  TCP 19.1  TLS 38.1)  HTTP 200
    Req  5:  TTFB    244.580 ms    (DNS  0.9  TCP 18.5  TLS 37.0)  HTTP 200

    Avg:    248.891 ms    Min: 243.891 ms    Max: 256.334 ms

  Phase 3: Results
  ---------------------------------------------

  +------------------------------------------------------------------+
  |                                                                  |
  |  Avg TTFB (raw)        248.891 ms                                |
  |  Avg Ping RTT           18.232 ms   (subtracted)                |
  |  --------------------------------                                |
  |  NTTFB                 230.659 ms   [NEEDS IMPROVEMENT]         |
  |                                                                  |
  |  NTTFB Min             224.786 ms                                |
  |  NTTFB Max             238.500 ms                                |
  |  NTTFB Range            13.714 ms                                |
  |                                                                  |
  |  TTFB Min              243.891 ms                                |
  |  TTFB Max              256.334 ms                                |
  |  Ping Min               17.834 ms                                |
  |  Ping Max               19.105 ms                                |
  |                                                                  |
  +------------------------------------------------------------------+

  Avg Breakdown:
    DNS Lookup             2.980 ms
    TCP Connect           18.720 ms
    TLS Handshake         37.400 ms
    Server Wait          189.791 ms
    Network Overhead      18.232 ms   (ping RTT, subtracted from total)

  Thresholds:
    Excellent      < 50ms     Edge cached or memory served
    Good           50-150ms   Healthy dynamic application
    Needs Work     150-400ms  Investigate backend bottlenecks
    Poor           >= 400ms   Serious server side issue

=================================================================================

8.2 WordPress Blog via Cloudflare (Johannesburg to EU West)

This example shows a typical scenario where the raw TTFB looks bad due to intercontinental latency, but the NTTFB reveals the server is actually performing well because the page is served from Cloudflare’s edge cache.

=================================================================================
  NTTFB: Net Time to First Byte
  Target: https://andrewbaker.ninja
  Samples: 5
=================================================================================

  Resolving host.............. andrewbaker.ninja -> 172.67.182.31

  Phase 1: Measuring Ping RTT (5 packets)
  ---------------------------------------------
    Ping  1:     161.223 ms
    Ping  2:     163.891 ms
    Ping  3:     162.445 ms
    Ping  4:     164.102 ms
    Ping  5:     162.887 ms

    Avg:    162.910 ms    Min: 161.223 ms    Max: 164.102 ms

  Phase 2: Measuring TTFB (5 requests)
  ---------------------------------------------
    Req  1:  TTFB    214.332 ms    (DNS 23.4  TCP 162.1  TLS 163.8)  HTTP 200
    Req  2:  TTFB    209.118 ms    (DNS  1.2  TCP 161.8  TLS 163.2)  HTTP 200
    Req  3:  TTFB    218.905 ms    (DNS  0.9  TCP 162.4  TLS 164.1)  HTTP 200
    Req  4:  TTFB    211.443 ms    (DNS  1.0  TCP 161.9  TLS 163.4)  HTTP 200
    Req  5:  TTFB    215.667 ms    (DNS  1.1  TCP 162.2  TLS 163.7)  HTTP 200

    Avg:    213.893 ms    Min: 209.118 ms    Max: 218.905 ms

  Phase 3: Results
  ---------------------------------------------

  +------------------------------------------------------------------+
  |                                                                  |
  |  Avg TTFB (raw)        213.893 ms                                |
  |  Avg Ping RTT          162.910 ms   (subtracted)                |
  |  --------------------------------                                |
  |  NTTFB                  50.983 ms   [GOOD]                      |
  |                                                                  |
  |  NTTFB Min              45.016 ms                                |
  |  NTTFB Max              57.682 ms                                |
  |  NTTFB Range            12.666 ms                                |
  |                                                                  |
  |  TTFB Min              209.118 ms                                |
  |  TTFB Max              218.905 ms                                |
  |  Ping Min              161.223 ms                                |
  |  Ping Max              164.102 ms                                |
  |                                                                  |
  +------------------------------------------------------------------+

  Avg Breakdown:
    DNS Lookup             5.520 ms
    TCP Connect          162.080 ms
    TLS Handshake        163.640 ms
    Server Wait         -117.347 ms
    Network Overhead     162.910 ms   (ping RTT, subtracted from total)

  Thresholds:
    Excellent      < 50ms     Edge cached or memory served
    Good           50-150ms   Healthy dynamic application
    Needs Work     150-400ms  Investigate backend bottlenecks
    Poor           >= 400ms   Serious server side issue

=================================================================================

The raw TTFB of 213ms would look concerning in a dashboard. But the NTTFB of 51ms tells the real story: the server (or in this case, Cloudflare’s edge) responded in about 51ms. The other 163ms was just the signal travelling from Johannesburg to Europe and back. That is physics, not a performance problem.

8.3 Higher Sample Count for Statistical Confidence

$ ./nttfb.sh -n 10 https://andrewbaker.ninja

Running with -n 10 doubles the samples. The output format is identical, with 10 ping lines and 10 request lines instead of 5. The additional samples tighten the averages and make the min/max range more representative. Use -n 10 or -n 20 when building a case for a performance report or evaluating a CDN migration.

8.4 Reading the Output

The key fields to focus on:

NTTFB is the headline number. This is your server processing time with network latency removed. Compare this against the thresholds.

NTTFB Min and Max show the range of server performance across the samples. A wide range (say 50ms+) suggests inconsistent server behaviour such as intermittent cache misses, connection pool contention, or garbage collection pauses.

NTTFB Range is the spread between min and max. A tight range (under 15ms) means the server is consistent. A wide range means something is variable and worth investigating.

Server Wait in the breakdown is the time between the TLS handshake completing and the first byte arriving. This is the closest approximation to pure application processing time. Note that when curl reuses connections, the TCP and TLS times may overlap or appear negative in the breakdown math because those phases were already completed.

DNS Lookup in the first request is typically higher than subsequent requests because the DNS result gets cached locally. This is normal behaviour and why taking multiple samples matters.

9 How NTTFB Min and Max Are Calculated

The min/max calculations are deliberately conservative to give you the widest reasonable range of server processing time:

NTTFB Min = Lowest TTFB - Highest Ping
NTTFB Max = Highest TTFB - Lowest Ping

This means NTTFB Min represents the best case: the fastest server response you saw, adjusted for the slowest network conditions. NTTFB Max represents the worst case: the slowest server response, adjusted for the fastest network. Together they bracket the true server performance range even when both network and server latency are varying between samples.

If NTTFB Min and NTTFB Max are close together, your server performance is stable and the measurement is reliable. If they are far apart, run the test again with a higher -n value. Persistent wide ranges indicate genuine server side variability.

10 Understanding the Breakdown

The “Avg Breakdown” section at the bottom of the output decomposes the total TTFB into its constituent phases using curl’s timing variables:

DNS Lookup (time_namelookup) is the time to resolve the hostname to an IP address. The first request typically takes longer because the DNS result is not yet cached. Subsequent requests within the same test run benefit from the local DNS cache, so you will often see values of 10ms+ for the first request and under 2ms for the rest.

TCP Connect (time_connect) is the time to establish the TCP connection. This is approximately one round trip time (RTT) because TCP uses a three way handshake. On a persistent connection, this may show as zero.

TLS Handshake (time_appconnect) is the time to negotiate TLS. This typically requires one to two additional round trips depending on the TLS version (TLS 1.3 achieves a one round trip handshake, TLS 1.2 requires two). Like TCP connect, this may show as zero when connections are reused.

Server Wait is a calculated field: total TTFB minus DNS minus TCP minus TLS. It represents the time between the request being sent (after the connection is fully established) and the first byte of the response arriving. This is the closest proxy for pure server processing time. Note that when curl reuses connections from previous requests, the TCP and TLS values may be near zero, which means Server Wait will include some connection overhead and may differ from the overall NTTFB.

Network Overhead is the ping RTT that gets subtracted from the total TTFB to produce NTTFB. It represents the baseline speed of light latency between you and the server.

11 Limitations and Caveats

11.1 Lab Metric Only

NTTFB is a lab and synthetic metric. It cannot be measured in Real User Monitoring (RUM) because browsers do not expose raw ICMP ping data through the Navigation Timing API. For field measurement, you would need to approximate RTT from the connectEnd - connectStart timing (TCP handshake duration) or use the Server-Timing header to have your server report its own processing time directly.

11.2 ICMP vs TCP Ping

ICMP packets may be rate limited, deprioritised, or blocked by some networks, which can skew the ping RTT measurement. The script detects this automatically and falls back to TCP handshake timing via curl. TCP ping is slightly higher than ICMP because it includes the kernel’s TCP stack overhead, so NTTFB values measured via TCP fallback may be a few milliseconds lower than they would be with ICMP. The script reports which method it used so you can compare like for like.

11.3 Connection Reuse

curl may reuse TCP connections between the TTFB requests within a single run. When this happens, the TCP and TLS timing values for subsequent requests will be near zero. This does not affect the overall TTFB measurement (curl’s time_starttransfer always measures from request start to first byte), but it means the per phase breakdown in later requests will look different from the first request. The averaged breakdown accounts for this naturally.

11.4 DNS Caching

The first TTFB request typically includes a full DNS lookup while subsequent requests benefit from the local DNS cache. This is reflected in the output where Req 1 often shows a higher DNS time than later requests. The averaging smooths this out. If you want to measure cold DNS performance specifically, run the script multiple times with sudo dscacheutil -flushcache (macOS) or sudo systemd-resolve --flush-caches (Linux) between runs.

11.5 Comparison Scope

NTTFB is most useful for comparing the same endpoint over time or comparing two backends serving the same content. It is less meaningful for comparing entirely different architectures (a static site versus a server rendered application) because the nature of the “work” being measured is fundamentally different.

11.6 CDN Edge vs Origin

If your CDN serves a cached response from an edge node that is geographically close to you, the ping RTT will be low and the TTFB will also be low. The resulting NTTFB represents the edge node’s cache response time, not your origin server’s processing time. This is actually the correct behaviour: you want to know how fast the user gets the content, and if the CDN is doing its job, the NTTFB should be in the “Excellent” range. If you specifically want to test origin performance, bypass the CDN by hitting the origin IP directly or using a cache busting query parameter.

12 Conclusion

TTFB mixes two fundamentally different things into one number: the time your server spent processing the request, and the time the signal spent travelling through cables you do not own across distances you cannot change. For global services with edge nodes everywhere, that conflation might be tolerable. For regional services (which is most services), it makes the metric nearly useless.

NTTFB strips out the uncontrollable latency and shows you the optimisation opportunity. It answers the question that actually matters: given that a user is wherever they are, how fast is your server responding? That number is the same whether the user is across town or across the planet, and every millisecond of it is something you can improve.

Save nttfb.sh to your MacBook. Run it against your services from different locations. When the NTTFB moves, you will know something real changed in your stack. When it does not move despite a TTFB increase, you will know the change is geography and you can stop chasing it.

Website Optimisation: Stop Waiting for Fonts

Stop Waiting for Fonts

Quick Guide to font-display: swap on macOS

Your website might be secretly blocking page renders while it waits for fancy custom fonts to load. This invisible delay tanks your Core Web Vitals and frustrates users. The fix is simple: font-display: swap.

Here’s how to audit your sites and fix it in minutes.

The Problem: FOIT

FOIT stands for Flash of Invisible Text. Here’s what happens:

By default, browsers do this:

  1. User navigates to your site
  2. Browser downloads HTML and CSS
  3. Browser sees @font-face rule for custom font (Montserrat, etc.)
  4. Browser blocks all text rendering and requests the custom font file
  5. Browser waits… waits… waits for the font file to download
  6. Font finally arrives, text renders

The problem? During steps 4-5, your page is completely blank. Users see nothing.

Timeline Example (Default Behavior)

0ms   - User clicks link
100ms - HTML loads, browser sees font request
110ms - Font download starts
500ms - [BLANK SCREEN - User sees nothing]
1000ms - Font arrives
1100ms - Text finally appears

On 4G or slow networks, fonts can take 2-3 seconds or longer. Your users are staring at a blank page. They think the site is broken. They click back. You lose them.

The Hidden Cost

This isn’t just about user experience. Google’s Core Web Vitals algorithm measures:

  • LCP (Largest Contentful Paint): How long until the largest visible element renders
  • If your fonts block rendering, your LCP score tanks
  • Bad LCP = lower search rankings

So FOIT costs you traffic, engagement, and SEO. All because of one missing CSS line.

The Solution: font-display: swap

With font-display: swap, the browser does this instead:

  1. Browser shows fallback font immediately (system font like Arial)
  2. Custom font loads in background (no blocking)
  3. Font swaps in when ready (silently, no jank)

Users see content immediately. Custom font upgrades happen silently. Everyone wins.

Timeline Example (With font-display: swap)

0ms   - User clicks link
100ms - HTML loads with fallback font (Arial)
150ms - TEXT APPEARS IMMEDIATELY ✓
200ms - Custom font download starts in background
1000ms - Custom font arrives
1050ms - Font swaps in (user barely notices)

Your page is readable in 150ms instead of 1100ms. That’s 7x faster.

Real User Impact

Without swap:

  • Blank screen for 1-3 seconds
  • Users think site is broken
  • Bounce rate spikes
  • LCP score: bad

With swap:

  • Text visible in 100-200ms using fallback
  • Custom font swaps in silently
  • No waiting, no jank
  • LCP score: good
  • Users stay

Audit Your Sites with a CLI Tool

Download the font-loader-checker tool to scan any website. It parses the HTML, extracts @font-face rules, and tells you exactly which fonts are optimized and which ones are blocking.

Let’s install and run it:

Step 1: Setup on macOS

Open Terminal and paste this entire block. It creates the checker script and makes it executable:

cat > font-loader-checker.js << 'EOF'
#!/usr/bin/env node

const https = require('https');
const http = require('http');

class FontLoaderChecker {
  constructor(options = {}) {
    this.timeout = options.timeout || 10000;
    this.verbose = options.verbose || false;
  }

  async check(targetUrl) {
    try {
      const urlObj = new URL(targetUrl);
      const html = await this.fetchPage(urlObj);
      const report = await this.analyzeHtml(html, urlObj);
      return report;
    } catch (error) {
      return {
        url: targetUrl,
        status: 'error',
        error: error.message
      };
    }
  }

  fetchPage(urlObj) {
    return new Promise((resolve, reject) => {
      const protocol = urlObj.protocol === 'https:' ? https : http;
      const options = {
        hostname: urlObj.hostname,
        path: urlObj.pathname + urlObj.search,
        method: 'GET',
        timeout: this.timeout,
        headers: {
          'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
        }
      };

      const request = protocol.request(options, (response) => {
        let data = '';

        response.on('data', (chunk) => {
          data += chunk;
          if (data.length > 5 * 1024 * 1024) {
            request.destroy();
            reject(new Error('Response too large'));
          }
        });

        response.on('end', () => {
          if (response.statusCode !== 200) {
            reject(new Error(`HTTP ${response.statusCode}`));
          } else {
            resolve(data);
          }
        });
      });

      request.on('timeout', () => {
        request.destroy();
        reject(new Error('Request timeout'));
      });

      request.on('error', reject);
      request.end();
    });
  }

  async analyzeHtml(html, baseUrl) {
    const fontLinks = this.extractFontLinks(html);
    const inlineStyles = this.extractInlineFontFaces(html);
    const externalFontContent = {};

    // Fetch and analyze external font CSS files
    for (const link of fontLinks) {
      try {
        const fullUrl = this.resolveUrl(link.url, baseUrl);
        const cssContent = await this.fetchPage(new URL(fullUrl));
        const fontFaces = this.extractFontFacesFromCss(cssContent);
        
        externalFontContent[link.url] = {
          fullUrl,
          fontFaces,
          hasFontDisplay: fontFaces.some(f => f.hasFontDisplay),
          optimizedCount: fontFaces.filter(f => f.isOptimized).length,
          totalCount: fontFaces.length,
          isDeferredMedia: link.media === 'print' || !!link.onload
        };
      } catch (e) {
        externalFontContent[link.url] = {
          error: e.message
        };
      }
    }

    const fontDeclarations = this.extractFontDeclarations(html);
    const hasOptimizedFonts = fontDeclarations.some(f => f.optimized) || 
                              Object.values(externalFontContent).some(c => c.hasFontDisplay);
    const unoptimizedCount = fontDeclarations.filter(f => !f.optimized).length +
                            Object.values(externalFontContent)
                              .filter(c => !c.error)
                              .reduce((sum, c) => sum + (c.totalCount - c.optimizedCount), 0);
    const totalFontFaces = inlineStyles.length + 
                          Object.values(externalFontContent)
                            .filter(c => !c.error)
                            .reduce((sum, c) => sum + c.totalCount, 0);

    return {
      url: baseUrl.href,
      status: 'success',
      summary: {
        totalFontDeclarations: totalFontFaces,
        optimizedFonts: totalFontFaces - unoptimizedCount,
        unoptimizedFonts: unoptimizedCount,
        hasFontDisplay: fontDeclarations.some(f => f.fontDisplay) || hasOptimizedFonts,
        isOptimized: hasOptimizedFonts && unoptimizedCount === 0,
        totalFontLinks: fontLinks.length,
        deferredFontLinks: fontLinks.filter(l => l.media === 'print' || l.onload).length
      },
      fontLinks: fontLinks,
      fontDeclarations: fontDeclarations,
      inlineStyles: inlineStyles,
      externalFontContent: externalFontContent,
      recommendations: this.generateRecommendations(fontDeclarations, fontLinks, externalFontContent, inlineStyles)
    };
  }

  extractFontDeclarations(html) {
    const fontDisplayPattern = /font-display\s*:\s*(swap|fallback|optional|auto|block)/gi;
    const matches = [];
    let match;

    while ((match = fontDisplayPattern.exec(html)) !== null) {
      const value = match[1].toLowerCase();
      const isOptimized = value === 'swap' || value === 'fallback' || value === 'optional';
      matches.push({
        value,
        optimized: isOptimized,
        fontDisplay: true
      });
    }

    return matches;
  }

  extractFontLinks(html) {
    const linkPattern = /<link[^>]+rel=["']?stylesheet["']?[^>]*>/gi;
    const hrefPattern = /href=["']?([^"'\s>]+)["']?/i;
    const mediaPattern = /media=["']?([^"'\s>]+)["']?/i;
    const onloadPattern = /onload=["']?([^"'\s>]+)["']?/i;
    
    const fonts = [];

    let match;
    while ((match = linkPattern.exec(html)) !== null) {
      const link = match[0];
      if (link.includes('font') || link.includes('googleapis') || link.includes('merriweather') || link.includes('montserrat')) {
        const hrefMatch = hrefPattern.exec(link);
        if (hrefMatch) {
          const href = hrefMatch[1];
          const mediaMatch = mediaPattern.exec(link);
          const onloadMatch = onloadPattern.exec(link);
          const isCrossOrigin = link.includes('googleapis') || link.includes('fonts.gstatic.com');
          
          fonts.push({
            url: href,
            isExternal: isCrossOrigin,
            media: mediaMatch ? mediaMatch[1] : 'all',
            onload: onloadMatch ? onloadMatch[1] : null,
            fullHtml: link
          });
        }
      }
    }

    return fonts;
  }

  resolveUrl(relativeUrl, baseUrl) {
    if (relativeUrl.startsWith('http://') || relativeUrl.startsWith('https://')) {
      return relativeUrl;
    }
    if (relativeUrl.startsWith('/')) {
      return `${baseUrl.protocol}//${baseUrl.host}${relativeUrl}`;
    }
    return new URL(relativeUrl, baseUrl).href;
  }

  extractInlineFontFaces(html) {
    const stylePattern = /<style[^>]*>([\s\S]*?)<\/style>/gi;
    const fontFacePattern = /@font-face\s*\{([^}]*)\}/gi;
    const fontFaces = [];

    let styleMatch;
    while ((styleMatch = stylePattern.exec(html)) !== null) {
      const styleContent = styleMatch[1];
      let fontMatch;
      
      while ((fontMatch = fontFacePattern.exec(styleContent)) !== null) {
        const fontFaceContent = fontMatch[1];
        const fontFamily = fontFaceContent.match(/font-family\s*:\s*['"]*([^'";\n]+)/i);
        const fontDisplay = fontFaceContent.match(/font-display\s*:\s*(swap|fallback|optional|auto|block)/i);
        
        fontFaces.push({
          fontFamily: fontFamily ? fontFamily[1].trim() : 'unknown',
          fontDisplay: fontDisplay ? fontDisplay[1].toLowerCase() : 'auto (blocking)',
          isOptimized: fontDisplay && (fontDisplay[1].toLowerCase() === 'swap' || fontDisplay[1].toLowerCase() === 'fallback' || fontDisplay[1].toLowerCase() === 'optional')
        });
      }
    }

    return fontFaces;
  }

  extractFontFacesFromCss(cssContent) {
    const fontFacePattern = /@font-face\s*\{([^}]*)\}/gi;
    const fontFaces = [];

    let match;
    while ((match = fontFacePattern.exec(cssContent)) !== null) {
      const content = match[1];
      const fontFamily = content.match(/font-family\s*:\s*['"]*([^'";\n]+)/i);
      const fontWeight = content.match(/font-weight\s*:\s*([^;]+)/i);
      const fontStyle = content.match(/font-style\s*:\s*([^;]+)/i);
      const fontDisplay = content.match(/font-display\s*:\s*(swap|fallback|optional|auto|block)/i);
      
      fontFaces.push({
        fontFamily: fontFamily ? fontFamily[1].trim() : 'unknown',
        weight: fontWeight ? fontWeight[1].trim() : '400',
        style: fontStyle ? fontStyle[1].trim() : 'normal',
        fontDisplay: fontDisplay ? fontDisplay[1].toLowerCase() : 'MISSING',
        hasFontDisplay: !!fontDisplay,
        isOptimized: fontDisplay && ['swap', 'fallback', 'optional'].includes(fontDisplay[1].toLowerCase())
      });
    }

    return fontFaces;
  }

  generateRecommendations(fontDeclarations, fontLinks, externalFontContent, inlineStyles) {
    const recommendations = [];

    // Check inline styles
    const unoptimizedInline = inlineStyles.filter(f => !f.isOptimized);
    if (unoptimizedInline.length > 0) {
      recommendations.push(`${unoptimizedInline.length} inline @font-face block(s) not optimized. Add font-display: swap.`);
    }

    // Check external font files
    let totalExternal = 0;
    let unoptimizedExternal = 0;
    for (const url in externalFontContent) {
      const content = externalFontContent[url];
      if (!content.error) {
        totalExternal += content.totalCount;
        unoptimizedExternal += content.totalCount - content.optimizedCount;
      }
    }

    if (totalExternal > 0 && unoptimizedExternal > 0) {
      recommendations.push(`${unoptimizedExternal} external @font-face block(s) not optimized. Add font-display: swap to CSS files.`);
    }

    // Check if fonts are deferred
    const deferredLinks = fontLinks.filter(l => l.media === 'print' || l.onload);
    const notDeferredLinks = fontLinks.filter(l => l.media !== 'print' && !l.onload);
    if (notDeferredLinks.length > 0) {
      recommendations.push(`${notDeferredLinks.length} font link(s) are render-blocking. Load with media="print" onload="this.media='all'" to defer.`);
    }

    // Check for preloading
    if (fontLinks.length > 0) {
      recommendations.push(`Consider adding rel="preload" as="style" to critical font links to improve performance.`);
    }

    if (recommendations.length === 0) {
      recommendations.push('Font loading appears well optimized!');
    }

    return recommendations;
  }
}

function formatReport(report) {
  if (report.status === 'error') {
    return `
Error checking ${report.url}
${report.error}
`;
  }

  const { summary, fontLinks, fontDeclarations, inlineStyles, externalFontContent, recommendations } = report;

  let output = `
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Font Loading Optimization Report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
URL: ${report.url}

Summary:
  Status: ${summary.isOptimized ? '✓ OPTIMIZED' : '✗ NOT OPTIMIZED'}
  Total Font Declarations: ${summary.totalFontDeclarations}
  Optimized: ${summary.optimizedFonts}
  Unoptimized: ${summary.unoptimizedFonts}
  Font Links Deferred: ${summary.deferredFontLinks}/${summary.totalFontLinks}
`;

  if (fontLinks.length > 0) {
    output += `
Font Links: ${fontLinks.length}
`;
    fontLinks.forEach((fl) => {
      const deferStatus = fl.media === 'print' || fl.onload ? '✓' : '✗';
      const deferText = fl.media === 'print' ? ' [media=print]' : (fl.onload ? ' [onload]' : ' [RENDER-BLOCKING]');
      output += `  ${deferStatus} ${fl.url}${deferText}\n`;
    });
  }

  if (inlineStyles.length > 0) {
    output += `
Inline Font Faces: ${inlineStyles.length}
`;
    inlineStyles.forEach((ff) => {
      const status = ff.isOptimized ? '✓' : '✗';
      output += `  ${status} ${ff.fontFamily}: ${ff.fontDisplay}\n`;
    });
  }

  if (Object.keys(externalFontContent).length > 0) {
    output += `
External Font CSS Files: ${Object.keys(externalFontContent).length}
`;
    for (const url in externalFontContent) {
      const content = externalFontContent[url];
      if (content.error) {
        output += `  ✗ ${url} - Error: ${content.error}\n`;
      } else {
        const status = content.optimizedCount === content.totalCount ? '✓' : '✗';
        const deferStatus = content.isDeferredMedia ? '✓' : '✗';
        output += `  ${status} ${url}\n`;
        output += `     Fonts: ${content.optimizedCount}/${content.totalCount} optimized\n`;
        output += `     Deferred: ${deferStatus}\n`;
        
        if (content.fontFaces.length > 0) {
          content.fontFaces.forEach((ff) => {
            const optStatus = ff.isOptimized ? '✓' : '✗';
            output += `       ${optStatus} ${ff.fontFamily} ${ff.weight}/${ff.style}: ${ff.fontDisplay}\n`;
          });
        }
      }
    }
  }

  if (recommendations.length > 0) {
    output += `
Recommendations:
`;
    recommendations.forEach((rec, index) => {
      output += `  ${index + 1}. ${rec}\n`;
    });
  }

  output += `
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
`;

  return output;
}

async function main() {
  const args = process.argv.slice(2);

  if (args.length === 0) {
    console.log(`
Font Loader Checker - Verify font-display optimization and deferring

Usage:
  ./font-test.sh <URL> [URL2] [URL3] ...
  ./font-test.sh https://example.com
  ./font-test.sh https://example.com https://another.com

Features:
  ✓ Checks inline @font-face blocks
  ✓ Fetches external CSS files and analyzes font-face blocks
  ✓ Detects font-display: swap declarations
  ✓ Checks if fonts are deferred (media="print" or onload)
  ✓ Shows detailed recommendations

Examples:
  ./font-test.sh https://andrewbaker.ninja
  ./font-test.sh https://example.com https://test.com
`);
    process.exit(0);
  }

  const urls = args.filter(arg => arg.startsWith('http://') || arg.startsWith('https://'));

  if (urls.length === 0) {
    console.error('Error: No valid URLs provided');
    process.exit(1);
  }

  const checker = new FontLoaderChecker();

  console.log(`\nChecking ${urls.length} site(s) for font optimization...\n`);

  for (const targetUrl of urls) {
    const report = await checker.check(targetUrl);
    console.log(formatReport(report));
  }
}

main().catch(console.error);
EOF
chmod +x font-loader-checker.js

Done. The tool uses only Node.js built-ins, no dependencies needed.

Step 2: Check Your Site

Test any website:

node font-loader-checker.js https://andrewbaker.ninja

Example output:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Font Loading Optimization Report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
URL: https://andrewbaker.ninja/

Summary:
  Status: ✓ OPTIMIZED
  Total Font Declarations: 43
  Optimized: 43
  Unoptimized: 0
  Font Links Deferred: 1/1

Font Links: 1
  ✓ https://andrewbaker.ninja/wp-content/themes/twentysixteen/fonts/merriweather-plus-montserrat-plus-inconsolata.css [media=print]

External Font CSS Files: 1
  ✓ https://andrewbaker.ninja/wp-content/themes/twentysixteen/fonts/merriweather-plus-montserrat-plus-inconsolata.css
     Fonts: 43/43 optimized
     Deferred: ✓
       ✓ Merriweather 400/normal: fallback
       ✓ Merriweather 400/normal: fallback
       ✓ Merriweather 400/normal: fallback
       ✓ Merriweather 400/normal: fallback
       ✓ Merriweather 400/normal: fallback
       ✓ Merriweather 700/normal: fallback
       ✓ Merriweather 700/normal: fallback
       ✓ Merriweather 700/normal: fallback
       ✓ Merriweather 700/normal: fallback
       ✓ Merriweather 700/normal: fallback
       ✓ Merriweather 900/normal: fallback
       ✓ Merriweather 900/normal: fallback
       ✓ Merriweather 900/normal: fallback
       ✓ Merriweather 900/normal: fallback
       ✓ Merriweather 900/normal: fallback
       ✓ Merriweather 400/italic: fallback
       ✓ Merriweather 400/italic: fallback
       ✓ Merriweather 400/italic: fallback
       ✓ Merriweather 400/italic: fallback
       ✓ Merriweather 400/italic: fallback
       ✓ Merriweather 700/italic: fallback
       ✓ Merriweather 700/italic: fallback
       ✓ Merriweather 700/italic: fallback
       ✓ Merriweather 700/italic: fallback
       ✓ Merriweather 700/italic: fallback
       ✓ Merriweather 900/italic: fallback
       ✓ Merriweather 900/italic: fallback
       ✓ Merriweather 900/italic: fallback
       ✓ Merriweather 900/italic: fallback
       ✓ Merriweather 900/italic: fallback
       ✓ Montserrat 400/normal: fallback
       ✓ Montserrat 400/normal: fallback
       ✓ Montserrat 400/normal: fallback
       ✓ Montserrat 400/normal: fallback
       ✓ Montserrat 400/normal: fallback
       ✓ Montserrat 700/normal: fallback
       ✓ Montserrat 700/normal: fallback
       ✓ Montserrat 700/normal: fallback
       ✓ Montserrat 700/normal: fallback
       ✓ Montserrat 700/normal: fallback
       ✓ Inconsolata 400/normal: fallback
       ✓ Inconsolata 400/normal: fallback
       ✓ Inconsolata 400/normal: fallback

Recommendations:
  1. Consider adding rel="preload" as="style" to critical font links to improve performance.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Green checkmarks = you’re good. Users won’t wait for fonts.

Step 3: Audit Multiple Sites

Check your entire portfolio in one command:

node font-loader-checker.js \
  https://andrewbaker.ninja \
  https://example.com \
  https://mysite.io \
  https://client-site.com

Expected output for unoptimized fonts:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Font Loading Optimization Report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
URL: https://example.com

Summary:
  Status: ✗ NOT OPTIMIZED
  Total Font Declarations: 2
  Optimized: 0
  Unoptimized: 2

Font Faces Found: 2
  ✗ Lato: auto (blocking)
  ✗ Playfair Display: auto (blocking)

Recommendations:
1. 2 font declaration(s) are not optimized. Use font-display: swap instead of auto or block.
2. Consider preloading 2 font link(s) with <link rel="preload" as="style">
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Red X = your fonts are blocking. Fix them next.

Step 4: Fix Unoptimized Fonts

If your site has custom fonts, add swap to the @font-face rules. Find your CSS and change:

@font-face {
  font-family: 'MyFont';
  src: url('myfont.woff2') format('woff2');
  font-display: auto;  /* BAD: blocks rendering */
}

To:

@font-face {
  font-family: 'MyFont';
  src: url('myfont.woff2') format('woff2');
  font-display: swap;  /* GOOD: shows fallback immediately */
}

Also handle external fonts (Google Fonts, etc.). Add preload headers to your HTML or server config.

Step 5: Re-run the Checker

After fixing, re-run the tool to confirm:

node font-loader-checker.js https://yoursite.com

All green checkmarks = problem solved.

Real World Example

Check multiple sites and save the report:

node font-loader-checker.js \
  https://example.com \
  https://test.com \
  https://another.com > font-report.txt
cat font-report.txt

This gives you a baseline. Share the report with your team. Track which sites need fixes.

Why This Matters

Font loading affects three critical metrics:

Largest Contentful Paint (LCP): Fonts block rendering. Swap avoids it.

Cumulative Layout Shift (CLS): Font swap can cause layout jank if not tuned. Preload helps.

First Input Delay (FID): Fonts don’t block interactivity, but they do block paint.

All three together = your Core Web Vitals score. Google weighs this heavily in search rankings.

Wrap Up

Font loading is invisible to end users, but it has huge impact on performance. With font-display: swap and a quick audit, you can:

  • Show text immediately using fallback fonts
  • Upgrade to custom fonts silently in the background
  • Improve Core Web Vitals
  • Boost SEO rankings

Check out the following for other similar tests: https://pagespeed.web.dev/

Start today. Audit one site. Fix the fonts. Ship it.

WordPress Space Cleanup: A Free WordPress Databas, Media Library Cleanup Plugin and PNG to JPEG convertor

If you run a WordPress site for any length of time, the database quietly fills with junk. Post revisions stack up every time you hit Save. Drafts you abandoned years ago sit there. Spam comments accumulate. Transients expire but never get deleted. Orphaned metadata from plugins you uninstalled months ago quietly occupies table rows nobody ever queries. On a busy blog or a site that has been running for several years, this accumulation can add up to tens of thousands of rows and hundreds of megabytes of wasted space.

I built CloudScale Cleanup to deal with this properly. It is a free, open source WordPress plugin that handles both database cleanup and media library cleanup: unused images, orphaned filesystem files, and image optimisation; through a clean admin interface with full dry run support so you can see exactly what will be deleted before anything is touched.

Github:

https://github.com/andrewbakercloudscale/wordpress-database-cleanup-plugin

You can download it here: cloudscale-cleanup.zip

What It Cleans

Database Cleanup

Post Revisions: Every time you update a post, WordPress stores a complete copy of the previous version. On an active blog this means hundreds of revision rows per post. CloudScale Cleanup removes revisions older than a configurable threshold (default 30 days), leaving recent ones intact.

Draft Posts: Posts saved as drafts but never published. The threshold (default 90 days) ensures you never accidentally lose something you were actively working on.

Trashed Posts: Posts moved to the WordPress trash. WordPress keeps them indefinitely. The plugin removes them after a configurable number of days.

Auto-Drafts: WordPress creates an auto-draft record every time you open the Add New Post screen. If you navigate away without saving, the empty record remains. These accumulate silently and are almost always safe to delete immediately.

Expired Transients: Temporary cached values stored in your options table by plugins and themes. After expiry WordPress should delete them, but many are never cleaned up. They are completely safe to delete and can number in the thousands on plugin-heavy sites.

Orphaned Post Meta: Metadata rows referencing post IDs that no longer exist. Left behind when posts are deleted without their associated metadata being cleaned up first.

Orphaned User Meta: The same problem for user accounts. When a user is deleted, their metadata rows often remain.

Spam Comments: Comments flagged as spam. A configurable threshold (default 30 days) gives you time to review false positives before they are permanently removed.

Trashed Comments: Comments you have manually moved to the comment trash, removed after a configurable threshold.

WordPress database cleanup plugin interface showing cleanup options

Image Cleanup

Unused Image: Attachments that exist in your media library but cannot be found anywhere on the site: not in post content, not as featured images, not in widget settings, theme mods, the site logo, or the site icon. The site logo and icon are always protected regardless of settings.

Orphaned Filesystem Files: Image files that exist physically on disk inside wp-content/uploads but have no corresponding WordPress attachment record in the database. These are typically left behind after failed imports, manual file operations, or plugin migrations.

Database cleanup results displaying removed items and space saved

Image Optimisation

CloudScale Cleanup can resize and re-compress JPEG and PNG images that exceed configurable maximum dimensions or quality thresholds. This is a destructive operation; it modifies the original files on disk; so the plugin requires explicit confirmation and strongly recommends taking a backup first.

WordPress image optimizer showing PNG to JPEG conversion settings

PNG to JPEG Converter

CloudScale Cleanup WordPress plugin interface screenshot

If you have ever looked at the size of your WordPress uploads folder and wondered why it is so large, the answer is almost certainly PNG files. A single PNG screenshot can easily be 3 to 8 MB. The same image saved as a JPEG at 85% quality is typically 200 to 400 KB; a reduction of 90% or more. Multiply that across dozens or hundreds of images and you are looking at gigabytes of wasted disk space and slower page loads for every visitor.

Most images uploaded to WordPress do not need to be PNGs. PNG is a lossless format designed for images where every pixel matters; technical diagrams, logos with transparency, pixel art. For photographs, screenshots, WhatsApp images, and the vast majority of blog content, JPEG is the correct format. The visual difference at 85% quality is imperceptible to the human eye, but the file size difference is enormous.

CloudScale Cleanup now includes a PNG to JPEG tab that handles this conversion directly inside your WordPress admin. Drop one or more PNG files onto the upload area, set your quality (1 to 100, default 85) and output dimensions, and hit Convert All. The converter uses the same chunked upload architecture that powers the database cleanup, so even very large PNG files are uploaded in small pieces that will not hit PHP upload limits or server timeouts. Once converted, you can download the JPEG directly or click Add to Media to send it straight into your WordPress Media Library.

A typical WordPress blog with 100 PNG images at an average of 4 MB each uses 400 MB of disk space. Converting to JPEG at 85% quality typically reduces that to around 40 MB. On sites with heavy image use, the savings run into multiple gigabytes.

The PNG to JPEG converter bypasses the standard WordPress 2 MB upload limit using chunked uploads. Instead of submitting the entire file through the WordPress media uploader, the browser slices the PNG into smaller pieces (defaulting to 1.5 MB each) and uploads them individually via separate AJAX requests. The server reassembles the chunks, then GD handles the conversion at your chosen quality and size. This lets you convert PNG files up to 200 MB without touching any PHP configuration or asking your host to raise limits.​​​​​​​​​​​​​​​​

Below is me using it from my iphone to post an article. Both Chatgpt and claude create pngs, so i just go to the addin and convert them to jpegs.

Mobile phone screenshot of WordPress cleanup plugin in use

Installation

  1. Download the plugin zip from: https://andrewninjawordpress.s3.af-south-1.amazonaws.com/cloudscale-cleanup.zip
  2. In your WordPress admin, go to Plugins → Add New → Upload Plugin
  3. Choose the zip file and click Install Now
  4. Click Activate Plugin
  5. Navigate to Tools → CloudScale Cleanup in the admin sidebar

If you have an opcode cache running (OPcache, Redis object cache, or a caching plugin like WP Rocket or W3 Total Cache), deactivate and reactivate the plugin after installation to ensure the new files are loaded cleanly.

Using the Plugin

The Dry Run

Before deleting anything, always run a dry run. Click the Dry Run — Preview button on the Database Cleanup tab. The plugin will scan your database and report exactly what it found; how many revisions, which draft posts, how many orphaned meta rows, without touching anything.

The output terminal shows each category with a count, and for items like draft posts it lists the individual post IDs, titles, and dates so you can make an informed decision before proceeding.

If a toggle is switched off, the category will show as SKIPPED (disabled) in the output. Toggle states are respected at the point of scanning, so what you see in the dry run accurately reflects what the actual cleanup will do.

Toggles

Each cleanup category has a toggle switch. Green means it will be included in the next scan or cleanup run. Grey means it will be skipped. Toggle settings are saved independently using the Save Selection button on each card.

This lets you permanently disable categories you never want touched — for example, if you deliberately keep old drafts as reference material, toggle off Draft Posts and it will never appear in scans.

Thresholds

The Cleanup Thresholds card controls the age cutoffs for each category. Every threshold prevents the cleanup from touching items that are too recent. Defaults are conservative:

  • Post revisions older than 30 days
  • Drafts older than 90 days
  • Trashed posts older than 30 days
  • Auto-drafts older than 7 days
  • Spam comments older than 30 days
  • Trashed comments older than 30 days

Adjust these to match your workflow. If you publish daily and never need to recover a revision more than a week old, set the revision threshold to 7 days. If you occasionally return to old drafts, set the draft threshold to 180 days or higher.

Running the Cleanup

Once you are satisfied with the dry run output, click Run Cleanup Now. The plugin will ask for confirmation, then process deletions in chunks to avoid PHP timeout limits. A progress bar tracks completion in real time. The output terminal logs each deleted item.

The chunked processing engine means the cleanup is safe to run on large sites. Even if you have 50,000 orphaned meta rows, the plugin processes them in batches of 50 and reports progress throughout rather than attempting a single massive query that could time out.

Scheduled Cleanup

The Settings tab includes a scheduler that registers a WordPress Cron job to run the database cleanup automatically on selected days at a configured hour.

A note on WordPress Cron: it is not a real cron. It is triggered by page visits. On a low-traffic site, a job scheduled for 3:00 AM may not run until the first visitor arrives that morning. For precise scheduling, disable WP-Cron in wp-config.php and add a real system cron:

# wp-config.phpndefine('DISABLE_WP_CRON', true);
# Server crontabn0 3 * * * curl -s https://yoursite.com/wp-cron.php?doing_wp_cron u003e /dev/null 2u003eu00261

Architecture Notes

The plugin is deliberately self-contained; a single PHP file plus two asset files with no external dependencies and no calls home. It uses WordPress’s own $wpdb for all database operations, respects WordPress nonces for AJAX security, and hooks into the standard admin menu and enqueue system.

Cleanup operations use a three-phase chunked engine: a start action builds the queue and stores it in a transient, a chunk action processes one batch and updates the transient, and a finish action reports the final summary and cleans up. This pattern means the browser never waits more than a few seconds for any single request, and the cleanup can handle arbitrarily large datasets without running into PHP execution time limits.

The toggle state is passed from the browser to the PHP handler on every scan and run request. The PHP handler never assumes toggles are on, if a key is missing from the POST data it treats that category as disabled. This means the dry run and the actual cleanup always respect the current on-screen state, not just whatever was last saved to the database.

Download

The plugin is free and open source.

Download CloudScale Cleanup v1.5.3

Install it, run a dry run, and find out how much cruft has been accumulating in your WordPress database.

What is Minification and How to Test if it is Actually Working

1. What is Minification

Minification is the process of removing everything from source code that a browser does not need to execute it. This includes whitespace, line breaks, comments, and long variable names. The resulting file is functionally identical to the original but significantly smaller.

A CSS file written for human readability might look like this:

/* Main navigation styles */
.nav-container {
    display: flex;
    align-items: center;
    padding: 16px 24px;
    background-color: #ffffff;
}

After minification it becomes:

.nav-container{display:flex;align-items:center;padding:16px 24px;background-color:#fff}

Same output in the browser. A fraction of the bytes over the wire.

The same principle applies to JavaScript and HTML. Minified JavaScript also shortens variable and function names where safe to do so, which compounds the size reduction.

2. Why it Matters

Every byte of CSS and JavaScript has to be downloaded before the browser can render the page. Unminified assets increase page load time, increase bandwidth costs, and penalise your Core Web Vitals scores. On mobile connections the difference between a minified and unminified asset bundle can be several seconds of load time.

The impact is not theoretical. A JavaScript file that is 400KB unminified is routinely 150KB or less after minification, before any compression is applied on top.

3. Why You Cannot Assume it is Working

Most teams believe minification is handled because something is configured to do it. A WordPress plugin is active. A CDN toggle is switched on. A build pipeline supposedly runs. Everyone moves on. Performance is declared handled.

But assumptions fail silently. Plugins get disabled during updates. Build steps get bypassed in deployment shortcuts. CDN configurations get overridden. The site continues to serve unminified assets while the team believes otherwise.

The only way to know is to measure it.

4. How to Test it From the Terminal

The script below checks your HTML, CSS, and JavaScript assets and colour codes the results. Green means likely minified. Red means likely not minified. Yellow means borderline and worth inspecting.

4.1 Create the Script

Paste this into your terminal:

cat << 'EOF' > check-minify.sh
#!/bin/bash

if [ -z "$1" ]; then
  echo "Usage: ./check-minify.sh https://yourdomain.com"
  exit 1
fi

SITE=$1

GREEN="\033[0;32m"
RED="\033[0;31m"
YELLOW="\033[1;33m"
NC="\033[0m"

colorize() {
  LINES=$1
  TYPE=$2

  if [ "$TYPE" = "html" ]; then
    if [ "$LINES" -lt 50 ]; then
      echo -e "${GREEN}$LINES (minified)${NC}"
    else
      echo -e "${RED}$LINES (not minified)${NC}"
    fi
  else
    if [ "$LINES" -lt 50 ]; then
      echo -e "${GREEN}$LINES (minified)${NC}"
    elif [ "$LINES" -lt 200 ]; then
      echo -e "${YELLOW}$LINES (borderline)${NC}"
    else
      echo -e "${RED}$LINES (not minified)${NC}"
    fi
  fi
}

echo
echo "Checking $SITE"
echo

echo "---- HTML ----"
HTML_LINES=$(curl -s "$SITE" | wc -l)
echo -n "HTML lines: "
colorize $HTML_LINES "html"
echo

echo "---- CSS ----"
curl -s "$SITE" \
| grep -oE 'https?://[^"]+\.css' \
| sort -u \
| while read url; do
    LINES=$(curl -s "$url" | wc -l)
    SIZE=$(curl -s -I "$url" | grep -i content-length | awk '{print $2}' | tr -d '\r')
    echo "$url"
    echo -n "  Lines: "
    colorize $LINES "asset"
    echo "  Size:  ${SIZE:-unknown} bytes"
    echo
done

echo "---- JS ----"
curl -s "$SITE" \
| grep -oE 'https?://[^"]+\.js' \
| sort -u \
| while read url; do
    LINES=$(curl -s "$url" | wc -l)
    SIZE=$(curl -s -I "$url" | grep -i content-length | awk '{print $2}' | tr -d '\r')
    echo "$url"
    echo -n "  Lines: "
    colorize $LINES "asset"
    echo "  Size:  ${SIZE:-unknown} bytes"
    echo
done
EOF

chmod +x check-minify.sh

4.2 Run It

./check-minify.sh https://yourdomain.com

5. Reading the Output

5.1 Properly Minified Site

Checking https://example.com

---- HTML ----
HTML lines: 18 (minified)

---- CSS ----
https://example.com/assets/main.min.css
  Lines: 6 (minified)
  Size:  48213 bytes

---- JS ----
https://example.com/assets/app.min.js
  Lines: 3 (minified)
  Size:  164882 bytes

5.2 Site Leaking Development Assets

Checking https://example.com

---- HTML ----
HTML lines: 1243 (not minified)

---- CSS ----
https://example.com/assets/main.css
  Lines: 892 (not minified)
  Size:  118432 bytes

---- JS ----
https://example.com/assets/app.js
  Lines: 2147 (not minified)
  Size:  402771 bytes

The line count is the key signal. A minified CSS or JavaScript file collapses to a handful of lines regardless of how large the source file was. If you are seeing hundreds or thousands of lines in production, your minification pipeline is not running.

Minification is not a belief system. It is measurable in seconds from a terminal. If performance matters, verify it.

Stop Selling Hampers: Why Enterprise Software Tiering Is a Self-Defeating Strategy

By Andrew Baker, CIO at Capitec Bank

There is a category of enterprise technology vendor whose approach to pricing is so fundamentally at odds with how purchasing decisions actually get made that it borders on self-defeating. Their commercial model is built on access gates, bundled tiers, and a deeply held belief that controlling what a customer can see before they pay is a form of leverage. It is not. It is anti-sales dressed up as a pricing strategy, and a generational reckoning is coming for every vendor that has not figured this out yet.

1. The Legacy Pricing Trap

You know the vendors I mean even if I will not name them here. Their pricing model is built on tiers, each one separated from the next by a gap that costs companies millions of dollars to cross. The product catalogue is divided up such that anything genuinely interesting sits in a tier that requires a significant commercial commitment before you can touch it. You cannot experiment with it. You cannot build intuition about it. You cannot develop the informed advocacy that would eventually lead your organisation to invest in it properly. The gate comes before the experience, and the gate is expensive.

2. The Bundle Illusion

2.1 The Christmas Hamper Problem

Buying enterprise software is nothing like buying a Christmas hamper. With a hamper you are happy saying you are buying “stuff”. You do not need to know what is in it or whether you will use every item. Enterprise technology does not work like that. You cannot tell your board you are buying “stuff” from vendor X, and you cannot tell your CISO that the solution to your identity management problem is definitely somewhere in the tier you just committed millions to. Every product needs to be evaluated, understood, and justified against a specific problem.

Yet the hamper model is precisely what tiered pricing enforces. Inside the bundle are products you actively use, products you decided were not fit for purpose, products you never knew existed until after signing, and products duplicated by something you already own. The vendor’s position is that the bundle is worth the price regardless of how much you consume. They are so magnificent, apparently, that clients should simply pay for the option to use the software.

2.2 The Duplicate Purchase Problem

I have watched this play out repeatedly. Organisations on a pricing tier that included a fully capable security product, who had gone out and bought the same capability from a competitor anyway, because nobody knew the bundled product was there, or had no basis for trusting it when a real problem arrived. The free tier philosophy exists precisely to prevent this. If engineers can experience a product before a commercial commitment is made, they build the knowledge and trust that makes the bundled product the obvious choice rather than the invisible one.

2.3 The Tier Reclassification Trap

It gets worse. These same vendors have a habit of quietly moving products that sit in your current tier up into a higher tier at contract renewal. The feature set you budgeted for and built processes around is now in the next tier up, and the message is clear: pay more or lose capability. The commercial logic from the vendor’s side is understandable in the narrow short-term sense. The strategic damage is severe and largely invisible until it is too late to reverse.

The practical consequence is that customers become genuinely reluctant to adopt new products within their current tier, even when those products are included and theoretically free to use. The rational response to a vendor that periodically reclassifies features upward is to avoid becoming dependent on anything you are not paying explicitly to retain. So the products sit unused. The integration work does not happen. The institutional knowledge does not develop. And the vendor wonders why adoption of their broader portfolio is lower than the addressable opportunity suggests it should be.

2.4 The Refresh Con

The vendor’s response to low adoption of bundled products is not to ask why included products go unused or what that says about product accessibility. It is to restructure the bundle, move items between tiers, throw out the tins of expired spam, and keep the price the same.

The hamper is still worth the same, apparently.

3. The Opposite Problem: Infinite SKU Proliferation

If the hamper vendor’s sin is bundling everything together and hiding it behind a price wall, there is a mirror image failure mode that deserves equal scrutiny: vendors who partition their product so aggressively that the act of purchasing it becomes a liability.

3.1 Everything Is a New Product

Some vendors have discovered that any incremental capability, no matter how basic, can be packaged as a distinct product with its own SKU, its own login portal, its own contract, and its own renewal cycle. Add basic monitoring dashboards to your core platform? That is a new product. Ship a critical security feature that any reasonable customer would consider table stakes? That is an optional add-on. Need audit logging? That will be a separate line item. Need API access to your own data? That is the enterprise tier.

The motivation is understandable from a short-term revenue perspective. Every new product creates a new upsell conversation. Every capability withheld is a future negotiation. But the cumulative effect is a catalogue so fragmented that no single person in your organisation, including the vendor’s own account team, fully understands what a customer has and has not purchased at any given point.

3.2 The Open Source Repackaging Con

A particularly cynical variant of this approach involves vendors who take well-maintained open source projects, wrap a thin commercial layer around them, and sell them back to enterprise customers as premium products. The underlying technology is freely available on GitHub. The vendor has added a logo, a billing system, and perhaps a support contract of debatable quality. The customer, often a non-technical procurement team acting on a vendor briefing, has no idea they are paying a significant annual fee for software they could have deployed themselves.

This is not innovation. It is arbitrage on organisational complexity, and it relies entirely on the purchasing side lacking the technical depth to identify what they are actually buying. When the technical teams find out, the reputational damage to the vendor is significant and difficult to recover from. Trust in enterprise software relationships, once broken by this kind of discovery, rarely fully repairs.

3.3 Harmful Product Combinations

The most operationally dangerous consequence of extreme product partitioning is that it becomes possible for customers to purchase combinations of products that are genuinely inadequate for the problem they are trying to solve. This is not a theoretical risk. It happens routinely when vendors slice their offering finely enough that no single package provides end-to-end coverage of a real-world use case.

This problem reaches its peak severity in the security sector. A customer buys a security product from a well-known vendor. They do a reasonable job of evaluating it. They sign a contract. They deploy it. And then something bad happens, and it turns out that the detection capability, the response capability, and the alerting capability they assumed were part of what they bought are actually three separate products, two of which they did not purchase.

The client will, entirely reasonably, point out that they believed they had bought protection. The vendor will point to the contract language. Both are correct and neither is useful. The outcome is a breach that the product was theoretically capable of preventing but practically did not, because the customer was sold a component when they needed a system.

Security vendors bear a particular responsibility here because the asymmetry of consequence is so severe. A misconfigured analytics platform costs you insight. A misconfigured or incomplete security product costs you everything. Vendors who knowingly design their packaging such that customers can inadvertently purchase inadequate protection are not just making a commercial miscalculation. They are making an ethical one.

3.4 The Fragmentation Tax

Beyond the security risk, extreme product partitioning imposes a significant and largely invisible operational burden on the customer. Multiple contracts. Multiple renewal dates. Multiple login portals. Multiple support relationships. Multiple sets of administrators. Multiple training requirements. The total cost of ownership of a fragmented product suite is consistently higher than any single-vendor analysis will suggest, because the integration costs, the context switching costs, and the organisational overhead of managing dozens of micro-relationships with the same vendor are distributed across departments and never appear in a single budget line.

The vendor sees this as customer stickiness. The customer eventually sees it as a hostage situation.

4. A Generational Reckoning

The generational problem this creates is profound and slow-moving enough that most of these vendors will not feel it until it is structurally very difficult to address. The technology leaders who bought into these platforms did so in an era where the vendor had sufficient market leverage to make the tier-based model stick. Those leaders are gradually retiring. The generation replacing them grew up with AWS free tier, with Cloudflare free tier, with open source everything, with the expectation that you experience a product before you commit to it. They have spent their formative professional years building instincts on platforms that trusted them with real capability before asking for money.

When those leaders sit across a procurement table from a vendor whose pitch begins with a multi-million dollar tier commitment required just to evaluate the relevant product set, the cultural mismatch is immediate and significant. It is not just a price objection. It is a philosophical incompatibility with how they believe technology decisions should be made. And unlike the previous generation of buyers who perhaps had fewer alternatives, this generation has grown up with genuinely competitive options that do not impose the same barriers.

The vendors who have built their commercial model on tier-based access restrictions have a window to adapt. That window is not permanent. Every year that passes without meaningful change to how they allow potential customers to experience their products is another cohort of future decision makers building their instincts and loyalties elsewhere.

The root cause of this strategic blindness is that there is no metric for the depth of understanding your product has in the marketplace. You cannot put it in a board report. You cannot trend it quarter on quarter. You cannot attribute it to a campaign or a sales motion. And because it is unmeasurable, short-sighted technology companies convince themselves the gap can be bridged through other means. So they invest in snappy Gartner acronyms that reframe existing capability as visionary innovation, and they deploy fleets of well-heeled sales teams whose job is to manufacture urgency and compress evaluation cycles before the prospect has time to develop genuine product intuition. It works, right up until it does not. The deal closes but the understanding never develops, and without understanding there is no organic advocacy, no internal champion who truly believes in the platform, and no resilience when the product disappoints.

Technology companies run by engineers tend to understand this instinctively. Engineers know what it means to learn by doing. They know the difference between reading documentation and actually building something. They know that genuine conviction about a technology comes from hands-on experience and cannot be manufactured by a sales process however well resourced. When engineers run product strategy, accessible pricing and free tier investment make intuitive sense because they have lived the experience themselves. When the company is run primarily by people whose mental model of selling is about controlling access and extracting maximum value at each gate, the free tier looks like revenue left on the table rather than pipeline being built. That framing error is expensive, and it compounds over time in ways that do not show up in any dashboard until the generational shift is already well underway.

5. Conclusion

The technology industry has a persistent tendency to over-invest in the proxies for value rather than value itself. Brand recognition, analyst rankings, conference presence, and content marketing are all proxies. They describe a product from a distance. They cannot substitute for the experience of building with it, debugging it at midnight, watching it absorb an attack, or navigating an outage with enough architectural understanding to maintain composure.

The vendors still operating on the hamper model are not just leaving pipeline on the table. They are actively training the next generation of decision makers to build loyalty elsewhere. The vendors operating on the infinite SKU model are doing something arguably worse: they are selling customers the illusion of capability without the substance of it, and in the security domain that distinction carries consequences that no contract clause can fully mitigate.

The ask is simple. Stop selling hampers. Stop selling fragments. Start selling products. Price them in a way that lets people touch them before they commit to them. Package them in a way that ensures a customer who buys your security product actually has security. Trust that what you have built is good enough to demonstrate its own value. If it is not, that is the more important problem to solve.

Andrew Baker is the Chief Information Officer at Capitec Bank in South Africa. He writes about enterprise architecture, cloud infrastructure, banking technology, and leadership at andrewbaker.ninja.

WordPress Totally Free Backup and Restore: CloudScale Backup Plugin – Does Exactly What It Says

I’ve been running this blog on WordPress for years, and the backup situation has always quietly bothered me. The popular backup plugins either charge a monthly fee, cap you on storage, phone home to an external service, or do all three. I wanted something simple: a plugin that makes a zip file of my site, stores it locally, runs on a schedule, and optionally syncs to S3. No accounts, no subscriptions, no mandatory cloud storage. The final straw for me was when my previous backup solution let me backup – but when I needed to restore, it would not let me restore unless I paid to upgrade to the premium version!

So I built one. It’s called CloudScale Free Backup and Restore. It’s completely free, and you can install it right now.

Github repo:

https://github.com/andrewbakercloudscale/wordpress-backup-restore-plugin

Download: cloudscale-backup.zip

CloudScale WordPress Backup Admin Screens:

CloudScale Backup plugin interface showing WordPress backup and restore options
CloudScale Backup plugin interface showing WordPress backup settings and options

1. What It Backs Up

The plugin backs up any combination of the following:

Core

  • The WordPress database — all posts, pages, settings, users, and comments
  • Media uploads (wp-content/uploads)
  • Plugins folder (wp-content/plugins)
  • Themes folder (wp-content/themes)

Other (shown only if present on your server)

  • Must-use plugins (wp-content/mu-plugins)
  • Languages and translation files (wp-content/languages)
  • wp-content dropin files (object-cache.php, db.php, advanced-cache.php)
  • .htaccess — your Apache rewrite rules and custom security directives
  • wp-config.php — flagged with a credentials warning, unchecked by default

Each backup is a single zip file. Inside it you’ll find a database.sql dump, the selected folders each in their own subdirectory, and a backup-meta.json file that records the plugin version, WordPress version, site URL, table prefix, and exactly what was included. That metadata matters when restoring to a different server.

2. How the Backup Works Internally

Database dump

The plugin detects whether mysqldump is available on your server. If it is, it uses that — fast, handles large databases cleanly, produces a proper SQL dump including all CREATE TABLE and INSERT statements. If mysqldump isn’t available (common on shared hosting), it falls back to a pure PHP implementation that streams through every table and writes compatible SQL. Either way you get a database.sql that can be imported with any standard MySQL client.

File backup

Files are added to the zip using PHP’s ZipArchive extension, available on virtually every PHP installation. The plugin walks each selected directory recursively and adds every file. There’s no timeout risk because it does not use shell commands for file backup, it streams directly in PHP.

Backup naming and location

Backups are stored in wp-content/cloudscale-backups/. On first run the plugin creates this directory and drops an .htaccess file inside it containing Deny from all, which prevents direct web access. Backup filenames use a short format encoding the content type; for example bkup_f1.zip is a full backup (database, media, plugins, themes), bkup_d2.zip is database only, and bkup_dm3.zip is database plus media. The sequence number increments and never overwrites an existing file.

Scheduled backups

Scheduled backups use WordPress Cron. You pick which days of the week and what hour (server time), and the plugin registers a recurring event. WordPress Cron fires when someone visits your site, so on very low traffic sites the backup may run a few minutes after the scheduled hour rather than exactly on it. If you need exact timing, add a real server cron job that hits wp-cron.php directly.

3. The Retention System

The retention setting controls how many backups to keep. Every time a backup completes, the plugin counts current backups and deletes the oldest ones beyond your limit. The default is 10.

The plugin shows a live storage estimate: it takes the size of your most recent backup, multiplies by your retention count, and compares that against current free disk space. A traffic light indicator: green, amber, red tells you at a glance whether you’re comfortable, getting tight, or at risk of filling the disk. This updates live as you change the retention number.

4. S3 Remote Backup

Local backups are only half the story. If your server dies entirely, local backups die with it. The plugin addresses this with built in S3 remote backup. After every backup, scheduled or manual, it automatically uploads the zip to an S3 bucket of your choosing using the AWS CLI. The local copy is always kept.

How it works

The plugin runs aws s3 cp after each backup completes. There are no PHP SDKs, no Composer dependencies, no AWS API keys stored in the WordPress database. It relies entirely on the AWS CLI binary installed on the server and whatever credential chain that CLI is configured to use. If the sync fails for any reason, the local backup is unaffected and the error is logged and displayed.

Requirements

The AWS CLI must be installed on the server. The plugin detects it automatically across the common install locations including /usr/local/bin/aws, /usr/bin/aws, and the AWS v2 installer default at /usr/local/aws-cli/v2/current/bin/aws. The S3 card shows a green tick with the detected version if found.

Install on Ubuntu/Debian (x86_64):

curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zip
unzip awscliv2.zip && sudo ./aws/install

Install on Ubuntu/Debian (ARM64 — Graviton, Raspberry Pi, Apple Silicon VMs):

curl "https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" -o awscliv2.zip
unzip awscliv2.zip && sudo ./aws/install

Install on Amazon Linux / EC2 (x86_64 and ARM):

sudo yum install -y aws-cli

Credentials

The plugin inherits whatever credentials the AWS CLI is configured with under the web server user. Three options in order of preference:

IAM instance role: attach an IAM role to your EC2 or Lightsail instance with the policy below. Zero configuration, no keys stored anywhere, automatically rotated. This is the right approach if you’re on AWS infrastructure.

Credentials file: run aws configure as the web server user (www-data on Ubuntu, apache on Amazon Linux). This writes ~/.aws/credentials which the CLI picks up automatically.

Environment variables: set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_DEFAULT_REGION in the server environment.

Minimum IAM policy

Replace YOUR-BUCKET-NAME with your actual bucket:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:PutObject", "s3:GetObject", "s3:ListBucket"],
    "Resource": [
      "arn:aws:s3:::YOUR-BUCKET-NAME",
      "arn:aws:s3:::YOUR-BUCKET-NAME/*"
    ]
  }]
}

PutObject is what actually uploads the backups. GetObject and ListBucket are useful if you ever retrieve a backup directly from S3.

Configuration and testing

In the S3 Remote Backup card, set your bucket name and key prefix (defaults to backups/), then hit Save S3 Settings. Use the Test Connection button to verify everything before your next scheduled backup runs — it writes a small test file to the bucket and reports either a success confirmation or the exact AWS CLI error, which tells you specifically whether the problem is a missing CLI, a credentials gap, a wrong bucket name, or an IAM permissions issue.

Once configured, every backup syncs automatically. The result appears in the backup progress panel: green on success with the destination path, red with the raw error message if something goes wrong.

Create your S3 bucket in the same AWS region as your server where possible. Cross-region uploads work fine but add latency and inter-region transfer costs.

5. Installing the Plugin

Step 1. Download the plugin zip from:

https://andrewninjawordpress.s3.af-south-1.amazonaws.com/cloudscale-backup.zip

Step 2. In WordPress admin, go to Plugins → Add New → Upload Plugin. Choose the downloaded zip and click Install Now.

WordPress plugin installation interface showing CloudScale SEO AI Optimiser setup process

Step 3. Activate the plugin.

Step 4. Go to Tools → CloudScale Backup and Restore.

No API keys, no account creation, no configuration wizard.

6. First Things to Configure

Set your schedule. Under the Backup Schedule card, enable automatic backups and tick the days you want it to run. The default is Monday, Wednesday, and Friday at 03:00 server time. Hit Save Schedule.

Set your retention. Under Retention and Storage, decide how many backups to keep. Watch the live storage estimate, if you’re on a small instance size with limited disk, keep this number modest. Ten backups gives you nearly two weeks of daily coverage or about a month of three days a week coverage.

Configure S3 if you want remote copies. See section 4 above. The Test Connection button makes verification fast.

Run your first manual backup. Under Manual Backup, tick the components you want and click Run Backup Now. On most sites the first full backup takes between 30 seconds and a few minutes depending on media library size.

7. Restoring a Backup

The Backup History card lists all your backups with filename, size, creation date, age, and type. For each backup you have three actions along the left: Download, Restore DB, and Delete.

Download streams the zip directly to your browser; useful for keeping an offsite copy or moving to a new server.

Restore DB unpacks and restores just the database from the included SQL dump, using native mysql CLI if available, otherwise PHP. The plugin reads backup-meta.json to verify compatibility before proceeding.

The restore process puts the site into maintenance mode for the duration and brings it back up automatically when done.

8. A Note on wp-config.php Backups

The plugin can optionally back up wp-config.php, but it is unchecked by default and flagged with a warning. This file contains your database hostname, username, password, and secret keys. Include it in a backup and that backup ends up somewhere it shouldn’t, and you’ve handed someone the keys to your database.

The case for including it: a wp-config.php backup is extremely valuable for full disaster recovery onto a blank server, because you don’t have to reconstruct your configuration from memory. The case against: routine backups don’t need it since the file rarely changes.

My recommendation: include it in occasional deliberate full disaster recovery backups that you store securely, and leave it unchecked in your daily automated backups.

9. What It Deliberately Doesn’t Do

No multisite support. The plugin is designed for standard single-site WordPress installations.

No incremental backups. Every backup is a full backup of the selected components. This keeps the code simple and the restore process reliable, at the cost of larger backup files.

No external service dependency. There is no cloud account, no licence check, no telemetry. The plugin does not phone home. S3 sync is entirely optional and uses your own bucket under your own AWS account.

10. Open Source

The plugin is open source. The code is all standard WordPress plugin PHP, a single main file plus a CSS and JS asset. No build steps, no Node dependencies, no compilation required. The entire plugin is designed to be auditable by anyone who can read PHP.

Go build something.

Download: cloudscale-backup.zip

Eliminating Render-Blocking JavaScript: The Easiest Core Web Vitals Win You’re Not Taking

If you’ve run your site through Google PageSpeed Insights and seen the “Eliminate render-blocking resources” warning, you’ve probably wondered why something that sounds so simple is so hard to actually fix. The answer is that WordPress makes it surprisingly easy to load JavaScript the wrong way — and surprisingly difficult to fix it without either a heavyweight performance plugin or hand-editing your theme.

CloudScale SEO AI Optimizer v4.9.4 adds a single checkbox that sorts this out.

What “render-blocking” actually means

When a browser parses your HTML and encounters a <script src="..."> tag, it stops. It fetches the script, executes it, and only then continues rendering the page. Every external JavaScript file loaded this way adds its full network round-trip time to your Time to First Contentful Paint — the metric Google uses most heavily in its Core Web Vitals assessment.

The fix is the defer attribute:

<!-- Blocks rendering — browser stops here and waits -->
<script src="/wp-content/plugins/something/script.js"></script>

<!-- Non-blocking — browser continues parsing HTML, runs script after -->
<script defer src="/wp-content/plugins/something/script.js"></script>

With defer, the browser downloads the script in parallel with HTML parsing and only executes it once the DOM is fully built. Your page paints immediately. The scripts still run, in order, shortly after — but by then the user is already looking at content.

Why not just use async?

async is the other non-blocking option and it sounds appealing, but it executes scripts the moment they finish downloading regardless of order. If script B downloads before script A but depends on it, things break. Most WordPress sites have jQuery as a dependency chain root — async would have it executing at an unpredictable moment, breaking virtually every theme and plugin that calls $(document).ready().

defer preserves execution order while still being non-blocking. It’s almost always the right choice.

The new toggle

In Settings → Optimise SEO → Features & Robots, there’s a new checkbox: Defer render-blocking JavaScript.

Enable it, save, and the plugin hooks into WordPress’s script_loader_tag filter — which intercepts every script tag WordPress outputs via its asset pipeline — and adds defer to each one.

A few scripts are excluded automatically because deferring them reliably breaks things:

  • jQuery and its migration shim
  • WooCommerce cart and checkout scripts
  • reCAPTCHA and hCaptcha
  • Elementor frontend
  • wp-embed

If something breaks after enabling the toggle, there’s an exclusions textarea where you can add script handle names or URL substrings — one per line — to restore normal loading for just that script without turning off the feature globally.

Verifying it works

The fastest way to confirm the change is working is with this one-liner:

cat > ./check-defer.js << 'EOF'
#!/usr/bin/env node
const https=require('https'),http=require('http'),url=require('url');
const args=process.argv.slice(2),verbose=args.includes('--verbose')||args.includes('-v'),target=args.find(a=>a.startsWith('http'));
if(!target){console.error('Usage: node check-defer.js <url> [--verbose]');process.exit(1);}
const C={reset:'\x1b[0m',bold:'\x1b[1m',green:'\x1b[32m',red:'\x1b[31m',yellow:'\x1b[33m',cyan:'\x1b[36m',grey:'\x1b[90m'};
const ok=`${C.green}✓${C.reset}`,fail=`${C.red}✗${C.reset}`,skip=`${C.grey}–${C.reset}`,warn=`${C.yellow}!${C.reset}`;
const SAFE_EXCLUSIONS=['jquery','jquery-migrate','jquery-core','wp-embed','wc-checkout','wc-cart','wc-add-to-cart','recaptcha','hcaptcha','google-tag-manager','gtag','elementor-frontend'];
function isIntentionallyExcluded(src){if(!src)return false;const s=src.toLowerCase();return SAFE_EXCLUSIONS.some(e=>s.includes(e));}
function fetch(u,r=0){return new Promise((res,rej)=>{if(r>5)return rej(new Error('Too many redirects'));const p=url.parse(u),lib=p.protocol==='https:'?https:http;const req=lib.request({hostname:p.hostname,port:p.port||(p.protocol==='https:'?443:80),path:p.path||'/',method:'GET',headers:{'User-Agent':'check-defer/1.0','Accept':'text/html'}},re=>{if([301,302,303,307,308].includes(re.statusCode)&&re.headers.location){const next=re.headers.location.startsWith('http')?re.headers.location:`${p.protocol}//${p.hostname}${re.headers.location}`;return fetch(next,r+1).then(res).catch(rej);}let b='';re.setEncoding('utf8');re.on('data',c=>{b+=c;});re.on('end',()=>res({status:re.statusCode,body:b}));});req.on('error',rej);req.end();});}
function parseScripts(html){const out=[];const re=/<script([^>]*)>/gi;let m;while((m=re.exec(html))!==null){const a=m[1],srcM=/\bsrc=["']([^"']+)["']/i.exec(a),typeM=/\btype=["']([^"']+)["']/i.exec(a),type=typeM?typeM[1].toLowerCase():null;out.push({src:srcM?srcM[1]:null,hasDefer:/\bdefer\b/i.test(a),hasAsync:/\basync\b/i.test(a),isNonJs:!!(type&&type!=='text/javascript'&&type!=='module'&&!type.includes('javascript'))});}return out;}
function short(src,base){if(!src)return'(inline)';try{const u=new url.URL(src,base),bh=new url.URL(base).hostname;return u.hostname===bh?u.pathname+(u.search||''):u.hostname+u.pathname;}catch{return src.slice(0,80);}}
(async()=>{
console.log('\ncheck-defer.js — Render-Blocking Script Audit');
console.log('─'.repeat(60));
console.log('Fetching: '+target+'\n');
let res;try{res=await fetch(target);}catch(e){console.error('Failed to fetch: '+e.message);process.exit(1);}
if(res.status!==200){console.error('HTTP '+res.status);process.exit(1);}
const scripts=parseScripts(res.body);
if(res.body.includes('CloudScale SEO'))console.log('✓ CloudScale SEO AI Optimizer detected\n');
const ext=scripts.filter(s=>s.src&&!s.isNonJs);
const inline=scripts.filter(s=>!s.src&&!s.isNonJs);
const nonJs=scripts.filter(s=>s.isNonJs);
const deferred=ext.filter(s=>s.hasDefer);
const asyncd=ext.filter(s=>s.hasAsync&&!s.hasDefer);
const blockingAll=ext.filter(s=>!s.hasDefer&&!s.hasAsync);
const blockingExpected=blockingAll.filter(s=>isIntentionallyExcluded(s.src));
const blockingUnexpected=blockingAll.filter(s=>!isIntentionallyExcluded(s.src));
console.log('External scripts');
console.log('─'.repeat(60));
console.log('  Total:                        '+ext.length);
console.log('  ✓ Deferred:                  '+deferred.length);
console.log('  ✓ Async (self-managed):       '+asyncd.length);
console.log('  ! Intentionally not deferred: '+blockingExpected.length+'  (safe — excluded by design)');
console.log('  '+(blockingUnexpected.length>0?'✗':'✓')+' Unexpected render-blocking:  '+blockingUnexpected.length+'\n');
if(blockingExpected.length>0){
  console.log('Intentionally not deferred: (plugin excludes these on purpose)');
  blockingExpected.forEach(s=>console.log('  ! '+short(s.src,target)));
  console.log();
}
if(blockingUnexpected.length>0){
  console.log('⚠  Unexpected blocking scripts (action needed):');
  blockingUnexpected.forEach(s=>console.log('  ✗ '+short(s.src,target)));
  console.log();
}
if(verbose&&deferred.length>0){console.log('Deferred scripts:');deferred.forEach(s=>console.log('  ✓ '+short(s.src,target)));console.log();}
if(verbose&&asyncd.length>0){console.log('Async scripts:');asyncd.forEach(s=>console.log('  ✓ '+short(s.src,target)));console.log();}
console.log('─'.repeat(60));
console.log('  – '+inline.length+' inline block(s) — cannot be deferred (expected)');
if(nonJs.length>0)console.log('  – '+nonJs.length+' non-JS block(s) — JSON-LD etc., ignored');
console.log('\n'+'─'.repeat(60));
if(blockingUnexpected.length===0){
  console.log('✓ PASS — defer is working correctly');
  if(deferred.length>0)console.log('   '+deferred.length+' script(s) successfully deferred');
  if(blockingExpected.length>0)console.log('   '+blockingExpected.length+' script(s) intentionally not deferred (e.g. jQuery) — this is correct');
}else{
  console.log('✗ FAIL — '+blockingUnexpected.length+' unexpected render-blocking script(s)');
  console.log('\n   Either enable "Defer render-blocking JavaScript" in');
  console.log('   Settings → Optimise SEO → Features & Robots,');
  console.log('   or these scripts may be loaded outside wp_enqueue_script.');
}
if(!verbose&&deferred.length>0)console.log('\nTip: run with --verbose to list all deferred scripts');
console.log();
})();
EOF
chmod +x ./check-defer.js && node ./check-defer.js https://andrewbaker.ninja

Run it before and after enabling the toggle. A passing result looks like:

check-defer.js — Render-Blocking Script Audit
────────────────────────────────────────────────────────────
Fetching: https://andrewbaker.ninja

(node:52863) [DEP0169] DeprecationWarning: `url.parse()` behavior is not standardized and prone to errors that have security implications. Use the WHATWG URL API instead. CVEs are not issued for `url.parse()` vulnerabilities.
(Use `node --trace-deprecation ...` to show where the warning was created)
✓ CloudScale SEO AI Optimizer detected

External scripts
────────────────────────────────────────────────────────────
  Total:                        5
  ✓ Deferred:                  3
  ✓ Async (self-managed):       0
  ! Intentionally not deferred: 2  (safe — excluded by design)
  ✓ Unexpected render-blocking:  0

Intentionally not deferred: (plugin excludes these on purpose)
  ! /wp-includes/js/jquery/jquery.min.js
  ! /wp-includes/js/jquery/jquery-migrate.min.js

────────────────────────────────────────────────────────────
  – 6 inline block(s) — cannot be deferred (expected)
  – 4 non-JS block(s) — JSON-LD etc., ignored

────────────────────────────────────────────────────────────
✓ PASS — defer is working correctly
   3 script(s) successfully deferred
   2 script(s) intentionally not deferred (e.g. jQuery) — this is correct

Tip: run with --verbose to list all deferred scripts

One thing this won’t fix

Inline scripts — blocks of JavaScript written directly into the HTML rather than loaded from an external file — cannot be deferred. The defer attribute only applies to external file loads, and WordPress’s script_loader_tag filter only fires for those. Inline scripts from page builders or themes that call document.write() or manipulate the DOM immediately are a separate problem requiring a different approach. For most standard WordPress blogs, however, the bulk of JavaScript is enqueued externally, and this change will clear the PageSpeed warning entirely.

The feature is free, in the plugin, and takes about three seconds to enable.

The Quantum Threat: Why the Encryption Protecting Your Data Today Won’t Survive Tomorrow

Published on andrewbaker.ninja | Enterprise Architecture & Banking Technology


There is a quiet revolution happening in physics laboratories around the world, and most of the people who should be worried about it are not paying attention yet. That is about to change. Quantum computing is advancing faster than anyone predicted five years ago, and when it matures, it will shatter the encryption that protects virtually everything we hold dear in our digital lives, bank transactions, medical records, state secrets, and the messages you send to your family.

This is not science fiction. It is an engineering problem with a hard deadline, and the deadline is closer than you think.

1. Let’s Start at the Beginning: What Is Encryption, Really?

Before we can understand the quantum threat, we need a clear picture of what encryption is and why it works.

Imagine you want to send a secret message to a friend. You agree on a secret code beforehand, say, shift every letter three positions forward in the alphabet, so “A” becomes “D” and “B” becomes “E”. Anyone who intercepts the message sees gibberish. Only your friend, who knows the shift rule, can decode it. That is the essence of encryption.

Modern encryption works on the same principle but uses mathematics instead of alphabet shifts. Specifically, it relies on mathematical problems that are trivially easy to do in one direction but astronomically hard to reverse. The classic example is multiplication. Take two large prime numbers, say, a number with 300 digits, and multiply them together. Any computer can do that multiplication in a fraction of a second. But if I hand you only the result and ask you to find the original two prime numbers, even the most powerful computers on Earth today would take longer than the age of the universe to work it out.

That difficulty is the foundation of most encryption you encounter every day.

2. The Algorithms We Rely On Right Now

The encryption landscape today rests on a relatively small number of foundational algorithms. Understanding them at a high level matters, because each has a different vulnerability profile against quantum attacks.

RSA (named after its inventors Rivest, Shamir, and Adleman) is the workhorse of public key cryptography. When your browser shows a padlock icon and establishes a secure HTTPS connection, RSA is almost certainly involved. It protects the handshake that sets up the encrypted tunnel. RSA’s security rests entirely on that multiplication problem described above, the difficulty of factoring large numbers.

Elliptic Curve Cryptography (ECC) is a more modern and efficient cousin of RSA. It provides the same level of security with much shorter key lengths, making it preferred in environments where computing power is constrained, think mobile devices, payment terminals, and IoT sensors. ECC underpins much of the TLS encryption used in banking APIs and mobile applications today. Its security rests on a related mathematical problem called the discrete logarithm problem on elliptic curves.

AES (Advanced Encryption Standard) is a symmetric cipher, meaning both parties use the same key. It is used to encrypt the actual data once RSA or ECC has established a secure channel. AES protects data at rest, encrypted hard drives, database columns, archived files. It is widely considered robust and is used by governments and militaries worldwide.

SHA (Secure Hash Algorithm) is not an encryption algorithm in the traditional sense but a hashing function. It converts any input into a fixed length fingerprint. Banks use SHA to verify data integrity, if even a single byte of a transaction record changes, the hash changes completely. SHA also underpins digital signatures, which prove that a document has not been tampered with and that it came from a verified source.

The TLS protocol (Transport Layer Security), which you encounter every time you see “https” in your browser, combines these algorithms. RSA or ECC negotiates a shared secret, AES encrypts the actual data flowing back and forth, and SHA verifies integrity. It is an elegant system that has served us well for decades.

3. Enter the Quantum Computer

A classical computer, the one in your laptop, your phone, the servers running your bank, processes information as bits. Each bit is either a 0 or a 1. Every calculation is a sequence of operations on these binary values.

A quantum computer uses quantum bits, or qubits. And here is where physics gets strange. A qubit can be a 0, a 1, or, thanks to a quantum property called superposition, effectively both at the same time. Furthermore, qubits can be entangled, meaning the state of one qubit is instantly correlated with the state of another, regardless of physical distance. These properties allow a quantum computer to explore enormous numbers of possible solutions simultaneously rather than one at a time.

For most problems, this does not help much. But for certain specific mathematical problems, quantum computers are not just faster, they are exponentially faster in ways that completely break the difficulty assumptions that encryption relies on.

In 1994, a mathematician named Peter Shor published an algorithm, now called Shor’s Algorithm, that runs on a quantum computer and can factor large numbers exponentially faster than any classical computer. When a sufficiently powerful quantum computer running Shor’s Algorithm exists, RSA and ECC are broken. Not weakened. Broken. What currently takes longer than the age of the universe takes hours.

A second relevant algorithm, Grover’s Algorithm, provides a quadratic speedup for searching through unstructured data. This halves the effective key length of symmetric algorithms like AES. AES-128 becomes roughly as secure as a 64-bit key, which is crackable. AES-256 becomes roughly equivalent to AES-128, still acceptable for now, but the margin has shrunk significantly.

4. The “Harvest Now, Decrypt Later” Problem

Here is the part that should genuinely alarm every security professional and every executive responsible for sensitive data.

Quantum computers powerful enough to break RSA and ECC do not exist today. The current state of the art, systems from IBM, Google, and others, have hundreds to a few thousand qubits, but they are error prone and nowhere near the scale needed to run Shor’s Algorithm on real encryption keys. Most credible estimates put that capability somewhere between five and fifteen years away.

So why does this matter today?

Because sophisticated adversaries, nation states in particular, are almost certainly already collecting encrypted data they cannot currently read. They are storing it, waiting. When quantum capability arrives, they will decrypt years of harvested communications and data. This is not speculation. It is a rational strategy, and it costs almost nothing to execute given how cheap data storage has become.

Consider what that means in practice. A message encrypted and transmitted today that remains sensitive in ten years, say, a diplomatic cable, a long term business strategy, or a patient’s medical history, is already compromised in principle. The lock has been photographed. The key just has not been cut yet.

For banking, this has profound implications. Long term financial records, customer identification data, credit histories, and interbank settlement data could all be sitting in harvested caches waiting for quantum decryption.

5. Post Quantum Cryptography: The Response

The good news is that the mathematical and cryptographic community has known about this threat for decades and has been working on solutions. These solutions go by the name Post Quantum Cryptography (PQC), or sometimes Quantum Resistant Cryptography.

The approach is straightforward in concept: replace the mathematical problems that quantum computers can solve easily with different mathematical problems that quantum computers cannot. Three main families of problems have proven promising.

Lattice based cryptography relies on the difficulty of finding short vectors in high dimensional geometric structures called lattices. Imagine a crystal with billions of dimensions, finding a specific point within it is computationally intractable for both classical and quantum computers. Lattice problems have been studied for decades and have strong theoretical underpinnings. The leading PQC algorithms, CRYSTALS-Kyber for key encapsulation and CRYSTALS-Dilithium for digital signatures, are lattice based.

Hash based cryptography builds security on the same SHA hashing functions already in widespread use. SPHINCS+ is the primary hash based signature scheme. Its security assumptions are more conservative and better understood than newer approaches, which makes it attractive for high assurance applications.

Code based cryptography is based on the difficulty of decoding certain types of error correcting codes. This is one of the oldest areas of post quantum research, with the McEliece cryptosystem dating to 1978.

6. The NIST Standardisation Process

The United States National Institute of Standards and Technology (NIST) recognised the urgency of this problem in 2016 and launched a multi year global competition to evaluate and standardise post quantum algorithms. Cryptographers from around the world submitted candidates, and the process involved years of public scrutiny, attempted attacks, and mathematical analysis.

In August 2024, NIST published its first set of finalised PQC standards. These are not experimental proposals, they are production ready specifications intended for immediate adoption.

The three initial standards are ML-KEM (based on CRYSTALS-Kyber, used for key encapsulation, establishing shared secrets), ML-DSA (based on CRYSTALS-Dilithium, used for digital signatures), and SLH-DSA (based on SPHINCS+, a hash based signature alternative). A fourth standard, FN-DSA (based on Falcon, another lattice based scheme optimised for smaller signature sizes), is expected to be finalised shortly.

These standards represent the global consensus on what quantum resistant cryptography looks like for the next generation of secure systems.

7. What This Means for Your Technology Stack

This is where things get very concrete and very expensive. The encryption algorithms described above are not isolated modules sitting in one place. They are woven into virtually every layer of modern technology infrastructure, and ripping them out and replacing them is a massive undertaking.

7.1 Data in Flight

Every TLS connection uses RSA or ECC for its handshake. That covers your web applications, your APIs, your service to service communication inside microservice architectures, your database connections, your message brokers, your load balancers, and your VPNs. All of it needs to be upgraded to support hybrid key exchange, a transitional approach that combines a classical algorithm with a post quantum one, providing protection even if one is compromised.

Modern versions of TLS (1.3) and the underlying libraries, OpenSSL, BoringSSL, and similar, are already adding support for post quantum key exchange. But every system that terminates TLS needs to be upgraded: web servers, API gateways, CDN edge nodes, load balancers, network appliances, HSMs (Hardware Security Modules), and more. Many of these have long hardware refresh cycles and embedded firmware that is difficult to update.

7.2 Data at Rest

AES-256 remains acceptable against quantum attacks, Grover’s Algorithm halves its strength, but 256-bit strength halved is still 128-bit equivalent strength, which is currently considered secure. The immediate priority for data at rest is therefore ensuring you are using AES-256 everywhere, not AES-128. Many legacy systems still use AES-128 or, worse, older algorithms like 3DES, which need to be remediated regardless of quantum concerns.

However, the key management infrastructure protecting your AES keys is another matter entirely. Those keys are typically encrypted or exchanged using RSA or ECC. If your key management system, whether that is a cloud KMS service, an on premise HSM cluster, or a custom solution, uses classical public key cryptography to protect AES keys, the chain of trust is broken at the key management layer even if the data encryption itself is quantum resistant. Key management infrastructure needs to be upgraded to use post quantum algorithms for key wrapping and key exchange.

7.3 Digital Certificates and PKI

Public Key Infrastructure (PKI) is the system of trust that underpins digital certificates, the mechanism that allows your browser to verify it is talking to your real bank and not an impersonator. Every certificate in use today is signed using RSA or ECC. Certificate authorities, certificate revocation mechanisms, OCSP responders, and the trust stores built into every operating system and browser all need to be migrated to post quantum signature schemes.

This is complicated by the fact that certificates have expiry dates measured in months to a few years, so the migration can be staged, but the root certificates at the top of the trust hierarchy are long lived and need early attention. Browser vendors and operating system providers are already working on this, but enterprise PKI environments, which often include private certificate authorities for internal services, need their own migration plans.

7.4 Secure Shell (SSH)

SSH is the protocol used to securely administer servers and network infrastructure. It uses RSA, ECC, and related algorithms for both host key authentication and user authentication. Every SSH server and client, which means virtually every Linux server, network device, and cloud instance, will need updated key types and algorithm preferences. The OpenSSH project has already added experimental support for post quantum key exchange, but enterprise environments need planned migration paths.

7.5 Code Signing and Software Supply Chain

Software companies sign their releases digitally so that operating systems and update mechanisms can verify that the software you are installing is genuine and has not been tampered with. These signatures use, you guessed it, RSA or ECC. A quantum capable adversary could forge signatures on malicious software. Migration to post quantum signature schemes for code signing is critical for long term software supply chain security.

7.6 Hardware Security Modules

HSMs are specialised hardware devices designed to perform cryptographic operations and store keys securely. They are the backbone of payment processing, certificate authorities, and high assurance key management. HSMs have long lifecycles, five to ten years is common, and many current generation devices have limited or no support for post quantum algorithms. Organisations need to inventory their HSMs and plan replacements or firmware upgrades accordingly. This is not cheap, and procurement lead times for specialised hardware can be long.

7.7 Internet of Things and Embedded Systems

Perhaps the most difficult part of the migration is embedded systems and IoT devices. Payment terminals, ATMs, smart meters, industrial control systems, and connected devices of every description run firmware with hardcoded cryptographic algorithms. Many cannot be updated remotely. Some cannot be updated at all. For the banking sector specifically, the number of deployed payment terminals and ATMs globally is enormous, and the logistics and cost of replacing or updating them is staggering.

8. The Banking Sector: A Special Case

Banks sit at the intersection of almost every dimension of this problem. They hold extraordinarily sensitive data about their customers, financial histories, identity documents, behavioural patterns, and they are governed by strict regulatory frameworks that mandate specific security controls. They operate complex ecosystems involving core banking systems that are decades old, modern digital banking platforms, real time payment rails, card networks, and a vast web of third party integrations.

The interbank settlement systems, the infrastructure through which banks settle obligations with each other, are critical national infrastructure. In South Africa, systems like SAMOS (the South African Multiple Option Settlement system) and the various payment clearing mechanisms operated by BankservAfrica represent the plumbing of the financial system. The cryptographic protections on these systems need to be quantum resistant before quantum threats materialise.

SWIFT, the global interbank messaging network, has already published guidance on post quantum migration timelines and is working on updates to its protocols. Card schemes including Visa and Mastercard are engaged in similar efforts. The PCI-DSS standard, which governs payment card security, will inevitably incorporate post quantum requirements in future versions.

Regulatory bodies globally are beginning to take notice. The Financial Stability Board has flagged quantum computing as a systemic risk. Central banks and prudential regulators are starting to ask questions about quantum readiness in their supervisory processes. Boards and executives who are not yet thinking about this should be.

9. Crypto Agility: The Architectural Principle That Changes Everything

One of the most important lessons from the post quantum migration is not specific to quantum at all. It is about a concept called crypto agility: designing systems so that cryptographic algorithms can be swapped out without fundamental architectural change.

Most systems built over the past twenty years hardcode specific algorithms deep in their implementations. Changing the algorithm means changing the code, testing the change, deploying it, a significant engineering effort multiplied across every system in the estate. If the entire industry had adopted crypto agile architectures from the beginning, the quantum migration would be an operational challenge rather than an existential one.

Going forward, every new system should be built with crypto agility as a first class requirement. Algorithm selection should be a configuration concern, not a code concern. Cryptographic operations should be encapsulated behind well defined interfaces that can be backed by different implementations. Key management systems should be designed to support multiple algorithm types simultaneously.

10. What Should You Be Doing Right Now?

The migration to post quantum cryptography is not a project that can be started when quantum computers become a near term reality. By then it will be too late. The harvest now, decrypt later threat means the window for protecting long lived sensitive data has already partially closed.

A practical roadmap looks something like this.

Start with a cryptographic inventory. You cannot protect what you cannot see. Every system, every data store, every API endpoint, every certificate needs to be catalogued with the algorithms it uses. This is tedious work, but it is foundational. Many organisations are surprised to discover how much classical cryptography is buried in unexpected places, legacy batch processes, backup systems, monitoring agents, and logging pipelines.

Assess the sensitivity and longevity of your data. Not all data needs the same level of urgency. Data that will be public in five years and is not sensitive today is a lower priority. Data that must remain confidential for twenty years, long term contracts, personal identification records, health records, needs to be protected now with quantum resistant methods or at minimum with hybrid approaches that add a post quantum layer on top of classical encryption.

Begin hybrid deployments for data in flight. Major cloud providers and CDN vendors already support hybrid key exchange in TLS. Enabling this configuration for internet facing services is a relatively low risk first step that provides immediate protection against harvest now, decrypt later attacks.

Plan your PKI migration. Identify your certificate authorities, understand your certificate inventory, and develop a migration plan for moving to post quantum signing algorithms. This is a long runway project given the dependencies on browser and OS trust stores, but the planning needs to start now.

Engage your hardware vendors. Ask your HSM vendors, network appliance vendors, and embedded system suppliers about their post quantum roadmaps. If they do not have credible answers, that should factor into your procurement decisions.

Build crypto agility into new systems. Every greenfield project should be designed from the outset to support algorithm agility. This is the easiest time to get it right.

Train your teams. Post quantum cryptography involves concepts that are unfamiliar to most engineers and architects. Building internal capability now pays dividends throughout the migration.

11. The Horizon

Quantum computing and post quantum cryptography are one of those rare convergences where the threat and the defence are both genuinely new. The mathematics is settled, we know what is broken and we know what the replacements are. What remains is the enormous operational challenge of migrating the world’s technology infrastructure.

The organisations that treat this as an urgent priority today will be in a strong position as quantum capability advances. Those that wait for the threat to become immediate will face a chaotic scramble to protect data that is already potentially compromised.

We are not at the end of the encryption era. We are at a transition point, and the post quantum era is already beginning. The NIST standards are published. The algorithms are ready. The only question is how quickly we can deploy them.

The padlock on your digital life is being changed. The question for every organisation is whether they will do it on their own terms and timeline, or be forced to do it in a panic when the quantum threat arrives.


Andrew Baker is Chief Information Officer at Capitec Bank. He writes about enterprise architecture, cloud technology, and the future of banking at andrewbaker.ninja.

Core Banking Is a Terrible Idea. It Always Was.

The COBOL apocalypse conversation this week has been useful, because it has forced the industry to confront something it has been avoiding for decades. But most of the coverage is stopping at the wrong point. Everyone is talking about COBOL. Nobody is talking about the architectural philosophy that COBOL gave birth to, the one that outlived the mainframe, survived the client server era, made it through the cloud revolution, and is still being sold to banks today with a straight face.

Core banking. The idea that you can package every conceivable banking function into a single platform, run it as a monolithic system, and call that an architecture. It was a reasonable compromise when banking was about cutting a cheque once a month and buying a house every twenty years. It is a completely useless approach to solving modern banking needs, and the fact that it has persisted this long is one of the most remarkable examples of institutional inertia in the history of enterprise technology.

This is a companion to my earlier article on the COBOL announcement that shook IBM’s stock price. That piece was about the death of COBOL as a moat. This one is about the death of the architectural philosophy that COBOL created, and why that second death is the one that actually matters.

1. Where Core Banking Came From

1.1 The Original Problem Was Real

To understand why core banking became so entrenched, you need to go back to where it started. The first computerised core banking systems emerged in the late 1960s and early 1970s, built in COBOL and running on IBM mainframes. The business problem they were solving was genuine and significant: banks had enormous volumes of transactions to process, they were doing it manually or with primitive automation, and they needed centralisation, speed, and reliability.

1.2 A Pragmatic Solution for a Narrow World

The solution was a single centralised computer that handled everything. Account management, transaction processing, interest calculation, fee charging, regulatory reporting, all of it in one place, in one codebase, with batch processing that ran overnight. Transactions were processed in groups at end of day because that was the technical reality of the hardware. Intraday balances required workarounds. The system was only accessible during banking hours. These were not design choices made out of laziness. They were pragmatic responses to the constraints of 1970s computing.

And it worked. For the banking reality of the 1970s, it worked extremely well. A customer visited one branch. They had a current account and perhaps a savings account. They wrote cheques. They took out a mortgage once in their adult life. The entire relationship was narrow, predictable, low volume, and slow moving. A batch processing system that updated balances overnight was entirely adequate for that world. The monolithic architecture made sense because the problem it was solving was genuinely monolithic.

1.3 When the Compromise Became the Convention

The architectural sin came later. It came when that original pragmatic compromise got packaged up, sold as a product, extended by vendors across decades, and eventually canonised as the correct way to build a bank’s technology. The compromise became the convention. The workaround became the standard. And by the time the banking world had changed beyond recognition, the core banking system had become too embedded, too expensive, and too complex to dislodge.

2. The Stuck Thought That Refuses to Die

2.1 How the Monolith Started to Fracture

By the 1980s and 1990s, banking had already changed enough that the monolithic core was showing its limitations. Banks were adding credit cards, mortgages, foreign exchange, investment products. Each of these added specialist systems, often with their own ledgers, their own data models, their own business logic. The monolith started to fracture, not by design but by accretion, as new modules were bolted onto an architecture that was never designed to accommodate them.

2.2 Vendors Responded by Building Bigger Monoliths

Vendors responded by building larger monoliths. Temenos, Oracle FLEXCUBE, Finacle, SAP Banking; these systems attempted to consolidate the sprawl by packaging more and more functionality into a single platform. The pitch was compelling: one vendor, one contract, one system of record, one throat to choke. For a generation of technology leaders who had lived through the nightmare of integrating dozens of incompatible specialist systems, the appeal was understandable.

2.3 The Cost of Change Became Prohibitive

But the packaging created a new problem. These systems were so comprehensive, so interconnected, and so deeply embedded in a bank’s operations that they became impossible to change without enormous risk and cost. Upgrading a core banking system became a multi year programme. Configuring a new product required navigating hundreds of interdependent parameters. Adding a feature that the vendor had not anticipated required either a costly customisation that would be deprecated in the next release, or a multi year wait for the vendor roadmap to catch up with the business need.

The result was that the rigid coupling of product features and core systems became inadequate, while the complexity protecting those systems kept growing. Banks found themselves in a situation where the cost of change was so high that they simply stopped changing. Instead they wrapped the core in middleware, built APIs around the edges, and told themselves that digital transformation was happening while the fundamental architecture underneath stayed frozen.

2.4 The Gap Between the Front Door and the Engine Room

Look at the screenshot below. This is an Oracle FLEXCUBE drawdown screen, the kind of interface that bank operations staff use every day in institutions that run major corporate and syndicated lending books. It is not a screenshot from 1998. This is the actual class of interface that was active in Citibank’s operations in August 2020.

Citibank Flexcube system interface form displaying banking platform fields

The screen is a form with unlabelled fields, cryptic component codes (COLLAT, COMPINTSF, DEFAUL, DFLFTC), checkbox columns with ambiguous headers, and no affordance whatsoever to indicate what selecting a given combination of options will actually do to real money. In the Citibank case, a contractor attempting to process a routine interest payment on the Revlon term loan instead initiated a full principal repayment of roughly $900 million dollars, to the wrong counterparties, with no confirmation step capable of catching the error before it cleared. Citibank recovered most of it after a long legal battle. They did not recover all of it.

This is not a UI design failure. It is an architectural one. The reason the FLEXCUBE interface looks the way it does is that it is trying to expose the full configurability of a system designed to handle thousands of product permutations across every banking function imaginable, through a single generalised screen. The monolith underneath has no concept of what a specific transaction is supposed to do in plain language terms. It has parameters. The operator maps parameters to intent. When that mapping is wrong, the transaction executes exactly as configured, not as intended.

A domain driven architecture inverts this. A payments domain knows what a principal repayment is. It has a specific workflow for authorising it. It has explicit confirmation gates appropriate to the size and type of transaction. It cannot be accidentally triggered by checking the wrong box on a generalised parameter screen because the operation exists as a named, typed, validated function rather than as a configuration state. The modern app on the customer’s phone and the modern interface on the operator’s screen share the same design philosophy because they are both built on top of systems that understand what they are doing. The engine room matches the front door.

The FLEXCUBE screenshot is not an embarrassing historical artefact. Banks running Oracle FLEXCUBE, Temenos T24, and Finastra Fusion are operating interfaces like this today, in production, across their most sensitive wholesale and retail operations. The Citibank incident was the moment the industry glimpsed what operational risk looks like when the complexity of a monolith is projected directly onto the people responsible for operating it.

3. Why the Monolith Cannot Serve Modern Banking

3.1 Modern Banking Has Different Problems

The problems modern banking needs to solve are completely different from the problems of 1970. A customer today might interact with their bank hundreds of times a month, not once. They expect real time balances, instant payments, instant lending decisions, personalised product recommendations, seamless integration with third party services, and the ability to open a new product in under two minutes. They expect the bank to know them across every product and every channel simultaneously. They expect changes to the product to happen in days, not years.

None of these requirements can be met by a batch processing system designed to update balances overnight. None of them are well served by a monolith where changing one component requires testing the entire system. None of them benefit from packaging every banking function into a single platform that can only scale vertically and can only be deployed as a whole.

3.2 The Monolith Cannot Serve Different Masters

The reason this distinction matters is not academic. When you force domains with fundamentally different characteristics into a single architectural model, you end up optimising for none of them and constraining all of them.

Payments needs to scale horizontally and instantly. On a major public holiday, payment volumes can spike to ten times normal load with almost no warning. In a monolithic core, scaling payments means scaling everything; the lending engine, the regulatory reporting module, the customer identity system, all of it, because they share infrastructure, share databases, and share deployment pipelines. You are paying to scale components that do not need to scale because the architecture cannot distinguish between them. And when the scaling event ends, you cannot scale down selectively either.

Lending has the opposite problem. A lending decision engine benefits from rich customer data, complex scoring models, and the ability to iterate rapidly on decision logic as credit conditions change. In a monolithic core, changing the lending decision model requires a change to the core system. That means a change freeze, a full regression test cycle across every function the monolith owns, a release management process designed for a system where everything is coupled to everything else. A lender who wants to tighten credit criteria in response to a deteriorating macroeconomic signal cannot do it in a day. They wait for the next release window.

Regulatory reporting needs something different again: a complete, immutable, auditable record of every state change in the system, queryable across arbitrary time ranges, accurate to the transaction. A monolith that was designed for operational speed is not designed for this. The data model optimised for processing transactions is rarely the data model optimised for reconstructing the history of those transactions for a regulator. Banks running monolithic cores typically solve this by building a separate reporting warehouse that ingests data from the core and tries to reconstruct an audit trail after the fact. That warehouse is always slightly wrong and everyone knows it.

Three domains, three completely different technical requirements, one architecture serving all of them badly. That is not a coincidence. It is what happens when the architecture is selected for comprehensiveness rather than fit.

3.3 What Domain Driven Architecture Actually Enables

What serves these requirements is a domain driven architecture where payments is a domain, lending is a domain, identity is a domain, notifications is a domain, and each of these domains owns its own data, exposes its own APIs, publishes its own events, and can be scaled, deployed, and changed independently of every other domain. When the payments domain needs to handle ten times the usual volume on a public holiday, it scales without touching the lending domain. When the product team wants to iterate on the lending decision engine, they do it without a change freeze on the rest of the bank. When regulatory requirements change the way identity must be handled, that change is contained to the identity domain rather than rippling through a monolith in unpredictable ways.

A domain driven architecture treats each of these as an independently deployable unit with clear ownership and explicit interfaces. Domains talk to each other by publishing events that other domains can consume, or by exposing APIs that other domains can call. They do not share databases. They do not share code. They own their own data and are responsible for keeping it consistent. When a domain changes, it publishes a new event schema or a new API version, and the downstream consumers can upgrade on their own schedule.

3.4 The Clean Sheet Answer

Here is the most telling evidence that core banking as an architectural philosophy is obsolete: virtually every new bank built in the last decade has explicitly rejected it.

Monzo and Starling built around domain driven design and event driven architecture from the ground up. They did not choose this approach because it was fashionable. They chose it because when you are starting from a clean sheet, building a monolithic core banking system is obviously the wrong answer to the problems you are actually trying to solve. The pattern works. It has been proven at scale. The only remaining argument for the monolithic core is switching cost and organisational inertia, and those are not architectural arguments.

4. The Vendor Trap

4.1 The Proposition and Its Hidden Costs

The core banking vendor market deserves its own examination, because it has been one of the primary mechanisms through which the stuck thought has perpetuated itself.

The major core banking vendors have built extraordinarily successful businesses on a straightforward proposition: banking is too complex for you to build yourself, so buy our platform and we will handle the complexity for you. For smaller and mid tier banks without large technology organisations, this proposition was often correct. The cost of building and maintaining a custom core was prohibitive, and the vendor platform, for all its limitations, was more reliable than what the bank could build in house.

But the proposition came with hidden costs that only became apparent over time. Implementation took years. Customisation was expensive and fragile. Upgrades required the kind of programme management that consumed entire technology departments. The vendor roadmap moved at the vendor’s pace, not the bank’s.

4.2 The Dependency Deepens Over Time

Most critically, the more deeply a bank embedded itself in a vendor’s platform, the more expensive it became to ever leave. This is the architectural equivalent of the MIPS pricing problem. Just as MIPS pricing gave IBM leverage over every new workload a bank wanted to run, core banking vendor contracts give those vendors leverage over every new product a bank wants to launch. The bank becomes dependent not just on the platform but on the vendor’s interpretation of what banking should look like, what products should be possible, what data models should exist. The vendor’s architecture becomes the bank’s architecture by default, and the bank’s ability to differentiate on technology becomes increasingly constrained.

4.3 The Complexity Moat Is by Design

The vendors know this. Their licensing models, their implementation dependencies, their proprietary data formats are all optimised to make the cost of leaving feel higher than the cost of staying. The limitations of the packaged approach have become undeniable but the switching costs have made change feel impossible. It is a very sophisticated form of the same complexity moat that COBOL built around the mainframe.

5. The Question Nobody in the Room Ever Asked

5.1 The Wrong Question, Asked Expensively

Here is what continues to baffle me about the generation of banking technology leaders who ran these programmes. Somewhere in every one of those core banking replacement journeys, there was a room full of smart people, expensive people, people with decades of enterprise software experience, and collectively they convinced themselves that the central question they needed to answer was: which vendor can replace my current mess with a different vendor’s mess?

5.2 The Requirements Document as a Form of Self Harm

Think about what that process actually looked like. A bank assembles a requirements document. That requirements document runs to hundreds of pages. It covers every feature, every workflow, every edge case, every regulatory obligation, every reporting requirement, every integration point the current system handles, and then, for good measure, it adds everything the business has ever wanted but never got. The team spends months on it. Consultants bill handsomely for it. It becomes a definitive statement of what the bank wants from its technology for the next twenty years.

And at no point in that process does the penny drop that writing a twenty year feature wishlist for a monolithic vendor platform is itself a form of institutional self harm. The very act of producing that document is an admission that you have outsourced your architectural thinking to a sales catalogue. You are not designing a technology strategy. You are shopping.

5.3 The Quote Arrives and Nobody Asks the Hard Question

Then the quotes come in. The implementation is going to take three years, minimum. The risk profile is enormous. The cost is tens of millions of dollars before a single client sees any benefit. The programme will consume your best people, freeze your change pipeline, and create the kind of organisational stress that makes good engineers leave. And somewhere in that moment, in every single one of those programmes, someone should have stood up and asked the question that apparently nobody ever did: what is in this for our clients?

Not for the vendor. Not for the compliance team. Not for the CIO who wants a modern system on their CV. For the clients.

5.4 Better MIS Is Not Client Value

The honest answer to what the programme delivers for clients is, in almost every case: nothing they will ever see.

Better MIS reports. Slicker ETL. A compliance model that does not require a spreadsheet army to maintain. Internal dashboards that no longer require a PhD to operate. These are real improvements. They are not nothing. But they are improvements the client will never encounter, never feel, and never benefit from in any direct way.

The standard defence is that operational resilience is client value. That a bank which cannot see its own positions clearly will eventually harm its clients through failure, mis-selling, or collapse. That argument is not wrong. But it proves too little. Operational resilience justifies modernising your reporting layer. It does not justify a three year programme, tens of millions of dollars, and a change freeze across your entire product organisation; and then delivering the same products, at the same price, through the same channels, to clients who were unaware anything had changed.

The clients were going to get the same products, at the same price, with the same service model, through the same channels. The account was still going to be the account. The loan was still going to be the loan. The three year programme, the tens of millions of dollars, the organisational disruption; all of it was being spent on internal plumbing dressed up as transformation.

5.5 The Uncomfortable Question That Was Never Asked

This is what makes the entire core banking replacement era so difficult to defend in retrospect. The industry hired armies of technologists, built enormous internal capability, and then concluded that the highest and best use of that capability was to manage the procurement of vendor platforms. The talent was real. The investment was real. The output was a new set of vendor dependencies that looked marginally more modern than the old ones and came with an implementation trauma that took years to recover from.

If that talent had been directed at building domain capability instead of managing vendor relationships, the outcome would have been categorically different. But that would have required someone in the room to ask the uncomfortable question: why are we paying all these people if the answer is always to pay a vendor to do the actual work?

6. The Exquisite Pain of the Core Banking Upgrade

There is a particular kind of suffering in enterprise technology that has no equivalent elsewhere in the industry. It is the core banking upgrade. And if you have never lived through one, you cannot fully appreciate the combination of expense, duration, risk, and ultimate anticlimax that defines the experience.

A typical core banking upgrade programme runs three to five years. It consumes hundreds of millions of dollars when you account for implementation partners, internal resource, parallel running, testing infrastructure, and the inevitable scope creep that accompanies any programme of this complexity. It occupies the attention of the most senior technology leadership in the organisation for its entire duration. It generates a programme governance structure so elaborate that the governance itself becomes a full time job. It dominates board reporting, risk committee agendas, and regulator conversations for years at a stretch.

And then it goes live. And the very best outcome, the outcome the programme director dreams about, the outcome that gets celebrated with a quiet internal announcement and a cautious all staff email, is that nobody noticed. Not the customers. Not the operations teams. Not the regulators. The system behaves exactly as it did before, processes the same transactions, produces the same outputs, and the only visible change is that the version number in the admin console has incremented.

That is the success case. Three years. Tens of millions of dollars. New leadership, because the old CTO either burned out or was quietly moved on sometime around year two. And the headline achievement is: we did not break anything.

The business case that justified the programme spoke of future capability. Once on the new platform, the bank would be able to launch products faster, integrate with partners more easily, respond to regulatory changes with less pain, and unlock features from the vendor roadmap that the old system could not support. Some of those things materialise. Many of them do not, or materialise so slowly that the business opportunity they were meant to serve has already been captured by someone else.

Because here is the uncomfortable truth about the features you were going to unlock after the upgrade: if a customer wanted them badly enough, they left before you finished the programme. The customers who stayed either do not care about those features, have adapted their behaviour around their absence, or are locked in by switching costs of their own. You spent three years and a hundred million dollars catching up to a market position you should have held five years ago, in a world that has moved on since then.

The cruelest part is the competitive dynamics. When a major bank announces a core banking replacement programme, the correct response from every competitor is quiet celebration. Not because you wish them ill, but because you know what is coming. That bank is about to disappear into an internal programme for three to five years. Their best technology people will be consumed by the upgrade. Their ability to ship new products will be constrained by change freezes. Their senior leadership will be distracted. Their risk appetite will contract because nobody wants a major incident during a core migration. They will emerge on the other side with a swollen balance sheet, exhausted teams, and technology leadership that has largely turned over, and they will need another year just to rediscover what they were doing before the programme started. During their upgrade cycle the bank will have essentially gifted all of their competitors an uncontested market space.

This is the final indictment of the monolithic core banking model. It does not just constrain your architecture. It periodically forces you to consume enormous organisational energy in programmes whose best case outcome is standing still, while your competitors who made different architectural choices are shipping features every sprint. The upgrade treadmill is not an accident. It is a structural consequence of the architecture, and it will not end until the architecture changes.

7. The Replacement Trap

The question, then, is what to do about it. And this is where the industry has consistently chosen the wrong answer.

When a bank decides to replace its core banking system, it produces a requirements document. That document captures everything the current system does, everything the business has wanted but never received, and everything compliance has been asking for since the last major programme. It is a comprehensive statement of what the bank needs from its technology for the next twenty years. The selection process that follows evaluates vendors against that document. The winning vendor is the one whose platform covers the most requirements at an acceptable cost with a credible implementation track record.

Notice what has just happened. The bank has selected a new system based on its ability to replicate the functional footprint of the old one. The selection criterion is comprehensiveness. The winner is the most capable monolith available. The bank has spent eighteen months and several million dollars in consulting fees to procure a more modern version of the architectural problem it was trying to escape.

Ripping out one monolithic core and replacing it with another, even a genuinely more capable one, does not change the fundamental constraint. The constraint is not the age of the technology. The constraint is the architectural model: a single system that owns all the data, embeds all the business logic, and must be deployed, upgraded, and changed as a whole. A newer monolith has a cleaner codebase and a more modern API layer. It has exactly the same properties that will make it unmovable in fifteen years. You will be having this conversation again. The vendors know this. It is not a flaw in their business model. It is the business model.

The problem is not the technology. The problem is the decision to package every banking function into a single platform and call that an architecture. No amount of re-platforming resolves that decision. It only defers the reckoning.

7.1 The Strangler Fig Is the Only Approach That Consistently Works

The right approach is to strangle the core incrementally, building new capabilities on modern domain architecture alongside the existing system, migrating workloads progressively, and shrinking the footprint of the legacy core until it either becomes so small that replacement is trivial or its remaining functions are so well isolated that they can be maintained indefinitely without constraining everything else. This is the strangler fig pattern applied to banking, and it is the only migration approach that consistently produces good outcomes at acceptable risk.

7.2 Why the Argument Keeps Getting Ignored

The reason this argument has been ignored for so long is partly organisational and partly commercial, and partly something more primitive than either.

The organisational problem is that incremental transformation is harder to fund and harder to explain to a board than a single big programme. A “core banking replacement” is a project. An “incremental domain migration” is a journey with no obvious end date, and boards are more comfortable writing cheques for projects than journeys. But that framing only explains the governance mechanics. It does not explain the appetite for it, the genuine enthusiasm that intelligent people bring to these programmes. That comes from somewhere else.

Everyone wants to be at the big reveal. The go live. The moment the new system takes over and the old one is switched off forever. There is a ribbon cutting energy to a major core banking replacement that a decade of quiet domain migration can never replicate. Careers are built around it. It has a name and a programme board and a war room and a launch date. It is the kind of thing you put at the top of your CV and describe in keynotes. The people running these programmes are not stupid and they are not cynical. They genuinely believe in the transformational moment. They want to be the ones who finally fixed it.

The problem is that technology is not a rehabilitation programme. You do not get ill, go to rehab, come out cured, and return to normal life. Banks that treat core banking replacement as rehab are back in the same room five years after go live, wondering why the new system is already calcifying, already accumulating the technical debt that made the old one unbearable, already generating the requirements document for the next replacement. The cycle is not a coincidence. It is what happens when you try to solve a continuous problem with a discrete event.

Technology change is an infinite game. There is no big reveal. There is no moment at which the architecture is finished and the organisation can declare victory and go home. The neobanks that built domain driven architectures did not do it in one programme. They did it continuously, deploying changes daily, evolving domain boundaries as the business changed, treating the architecture as a living thing that requires constant attention rather than a project that can be completed and closed. That is not a less exciting way to run technology. It is the only way that actually works.

The commercial dimension reinforces this dynamic, and it does not require conspiracy to explain. The vendors who benefit most from large replacement programmes are the same vendors with the most presence at industry events, the most investment in thought leadership, and the most seats at the tables where technology strategy gets discussed. They do not need to coordinate. They just need to keep showing up, sponsoring the conferences, funding the research, and making the case for the kind of programmes that happen to be good for their revenue model. A vendor whose business depends on large implementation programmes has no commercial incentive to sell you a philosophy of incremental continuous change. The big reveal is very good for their business. The infinite game is not. The result is an industry conversation that is shaped, without anyone necessarily intending it, by the organisations with the most to gain from the status quo.

8. The Objections That Miss the Point

8.1 The Wheelchair Argument

There is a class of objection to the domain migration argument that surfaces reliably in every serious conversation about replacing COBOL and core banking systems. It goes something like this: AI will never replicate the performance of those undocumented hand optimised assembly routines that the COBOL engineers wrote to extract maximum throughput from the mainframe. Therefore AI cannot replace COBOL.

This objection is technically accurate and entirely irrelevant. It is the equivalent of arguing that electric vehicles cannot replace combustion engines because an electric motor cannot replicate the precise combustion dynamics of a finely tuned V8. You are not trying to replicate the V8. You are trying to move people from one place to another, and electric motors do that better by most measures that actually matter.

The assembly optimisation objection assumes that the goal is to take the existing system and make AI run it faster. It is not. The argument in this article; and the argument the neobanks proved in practice; is that you do not start with a wheelchair and strap a rocket to it. You build something that was designed from the beginning to go fast. A domain built specifically to process payments at scale, running on modern hardware, with a data model designed for throughput rather than retrofitted from 1972, does not have the same performance constraints as a COBOL batch processor. It does not need to replicate the assembly tricks because it does not have the architectural problems those tricks were invented to solve.

The question is not whether AI can match the performance of a hand optimised mainframe routine. The question is whether a modern domain architecture can meet the actual performance requirements of a modern bank. The answer to the second question has been demonstrated conclusively. The answer to the first question is irrelevant to anything anyone is actually trying to build.

8.2 The CAP Theorem Objection

The other objection that follows closely is the distributed systems consistency argument. ACID transactions are impossible on distributed systems. You cannot have the same transactional guarantees across domain boundaries that you have inside a monolith. Therefore domain driven architecture cannot replace a core banking system.

This one has the additional quality of being technically outdated.

ACID compliance within a single domain is not meaningfully different from ACID compliance within a monolith, because a well designed domain owns its own data store and processes its own transactions with full consistency guarantees. The complexity arises at domain boundaries, in operations that span multiple domains, and this is where the objection has historically had some validity.

What the objection misses is that this is a solved architectural problem, and has been for years. Saga patterns manage distributed transactions across domain boundaries by breaking them into a sequence of local transactions with compensating transactions for rollback. Event sourcing provides an immutable audit log of every state change, giving you the historical consistency that regulators require without forcing every domain to share a database. Eventual consistency, applied correctly to the parts of a banking system where it is appropriate, is not a concession. It is a design choice that matches the actual consistency requirements of the operation in question.

Not every banking operation requires synchronous ACID consistency across the entire system. A payment confirmation can be issued once the payments domain has committed its local transaction and published its event, even before the downstream ledger domain has processed that event. The money has moved. The question is only how quickly all systems agree that it has moved, and modern distributed systems close that window to milliseconds.

The banks that raise the CAP theorem objection as a reason not to migrate are not grappling with a genuine technical constraint. They are using a theoretical framework to avoid a difficult organisational decision. The constraint is not the theorem. The constraint is the willingness to make the transition.

8.3 The dual-book problem

You don’t need to run two books. The migration strategy isn’t a lift-and-shift of the book of record — you leave the existing ledger exactly where it is and use its own APIs to post debits and credits while you extract business logic and products out into separate domains incrementally. The core banking system becomes a dumb ledger temporarily, not something you have to replicate in parallel. The regulatory complexity argument collapses because you’re not running two books — you’re re-routing product logic while the same ledger of record continues to serve as the source of truth throughout. The UBS/Credit Suisse situation is actually a perfect illustration of what not to do: that was a forced, wholesale client migration across incompatible platforms under acquisition pressure. Domain extraction from a living system is a fundamentally different problem.

8.4 Neo-banks only doing the easy parts

Scale before complexity is rational product sequencing, not a ceiling. And neo-banks are already tackling things that universal banks genuinely struggle with: BNPL, crypto custody and rails, advanced payment infrastructure, real-time cross-border settlement. These aren’t simple — they’re just differently complex. Low-frequency, high-complexity products like commercial lending, mortgages, and IB aren’t being ignored; they’re next. When neo-banks get there, they’ll apply the same decomposed, domain-driven architectural philosophy they’ve already proven at scale — which gives them a structural advantage over incumbents who’ve been bolting those products onto monolithic cores for decades.

9. The Moment That Changes the Calculation

9.1 AI Dissolves the “Understanding Is Too Expensive” Argument

The AI announcement that rattled IBM’s stock this week is relevant to this argument in a specific and limited way. The claim that AI can compress the COBOL analysis phase from months to weeks is also a claim that it can compress the domain decomposition analysis of a core banking system. The same capability that traces data flows and business logic dependencies across hundreds of thousands of lines of COBOL can produce the domain boundary map that has historically cost millions in consulting fees before a single line of new architecture is written.

This matters because one of the most durable objections to domain driven migration has been that understanding what the current system actually does is itself prohibitively expensive. Nobody alive knows the full behaviour of a system built over forty years. The documentation is wrong or missing. The institutional knowledge has retired. The only way to know what the monolith does is to run it and watch it, which means the analysis phase alone is a multi year programme before migration even begins. If AI genuinely compresses that phase by an order of magnitude, it removes the single most credible technical objection to starting.

The hard problems remain hard. Data migration, regulatory validation, parallel running, cutover risk, the organisational change management required to shift a bank’s operating model from vendor dependency to domain ownership — none of that gets easier because a language model can read COBOL faster. But the argument that the problem is too complex to even understand clearly has just become significantly less convincing.

9.2 The Honest Conversation Cannot Be Deferred Indefinitely

For technology leaders at established banks, the variables are now shifting in a way that makes continued deferral harder to justify. The neobanks have moved from proof of concept to proven at scale — Monzo and Starling are no longer experiments, they are operational competitors with cost structures and change velocities that the monolithic architecture cannot match. AI is reducing the cost of understanding the problem. And the regulatory environment in most major markets is moving toward open banking requirements that a monolithic core serves badly and a domain architecture serves naturally.

The question is not whether the transition to domain driven banking architecture will happen. The neobank evidence has settled that. The question is whether established banks will lead that transition or react to it after competitors have used it to take market position they cannot recover.

Every year of continued dependency on a monolithic core is a year in which the cost of the eventual transition grows, the competitive gap widens, and the argument for delay gets slightly weaker. The switching cost is real. It has always been real. But it is not a permanent barrier. It is a cost that compounds the longer it is avoided.

9.3 The Architecture Reflects the Belief

The deepest reason core banking persisted is not technical and not commercial. It is a belief, held by successive generations of banking technology leaders, that banking is too complex and too regulated to be built any other way. That belief was never correct. It was a rationalisation that the vendors reinforced because it was commercially useful to them, and that technology organisations accepted because it released them from the responsibility of building genuine architectural capability.

The neobanks disproved it. They built domain driven banks, at scale, under the same regulatory frameworks, with teams a fraction of the size of the technology organisations at major incumbents. They did not discover a secret. They simply refused to accept the premise that the only architecture available to a bank was the one the vendors were selling.

The monolithic core had its era. That era was the 1970s. The question worth asking now is not how to replace it with a better monolith. It is how to build a bank whose architecture reflects what banking has actually become, rather than what it was when the architecture was first designed.


Andrew Baker is Chief Information Officer at Capitec Bank. He writes about enterprise architecture, banking technology, and the future of financial services technology at andrewbaker.ninja.

CloudScale SEO AI Optimiser: Enterprise Grade WordPress SEO, Completely Free

Written by Andrew Baker | February 2026

I spent years working across major financial institutions watching vendors charge eye-watering licence fees for tools that were, frankly, not that impressive. That instinct never left me. So when I wanted serious SEO for my personal tech blog, I built my own WordPress plugin instead of paying $99/month for the privilege of checkbox features.

The result is CloudScale SEO AI Optimiser, a full featured WordPress SEO plugin with Claude AI powered meta description generation. You download it once, install it for free, and the only thing you ever pay for is the Claude API tokens you actually use. No subscription. No monthly fee. No vendor lock-in.

Here’s what it does, how to get it, and how to set it up in under ten minutes.

Github Repo:

https://github.com/andrewbakercloudscale/wordpress-seo-ai-optimizer

How does it rank vs other SEOs: https://andrewbaker.ninja/2026/03/08/next-generation-ai-seo-for-wordpress-just-launched-and-its-totally-free/

1. What Does It Do?

The plugin covers the full SEO stack that a serious WordPress site needs:

Structured Data and OpenGraph. Every post gets properly formed JSON-LD schema markup: BlogPosting, Person, and WebSite schema so Google understands who you are and what you write. OpenGraph and Twitter Card tags mean your posts look great when shared on LinkedIn, X, or WhatsApp.

Sitemap. A dynamic /sitemap.xml generated fresh on every request. Publish a post and it appears in your sitemap immediately. No caching, no stale data, no plugins fighting over file writes. Submit the URL to Google Search Console once and you’re done.

Robots.txt. Full control over your robots.txt directly from the dashboard. Block AI training bots if you want, or leave them open if you want your content distributed through AI assistants (I leave mine open). Handles subdirectory WordPress installs and detects physical robots.txt files that would override your settings.

AI Meta Descriptions. This is the part that separates it from every free SEO plugin. Claude AI reads each post and writes a proper meta description, not a truncated excerpt, but a real 140–160 character summary written for humans. You can generate all missing descriptions in one batch, fix descriptions that are too long or too short, or set up a scheduled nightly run so new posts are always covered automatically.

noindex Controls. One click noindex for search result pages, 404s, attachment pages, author archives, and tag archives. All the things that waste Google’s crawl budget and dilute your rankings.

2. The Cost Model: Why This Is Different

Every major SEO plugin follows the same commercial model: free tier that does almost nothing, then $99–$199/year to unlock the features you actually want.

This plugin flips that entirely. The plugin itself is free and open. The only cost is Claude API or Google Gemini tokens when you run AI generation, and the numbers are tiny.

Claude Haiku (the model I recommend for bulk generation) costs roughly $0.001–$0.003 per post. If you have 200 posts and want AI generated descriptions for all of them, you’re looking at around $0.20–$0.60 total. A one time charge. After that, you only pay when new posts need descriptions, a few tenths of a cent each time.

Compare that to $99/year for a premium SEO plugin and the maths are not close.

3. Download and Install

Step 1: Download the plugin

Download the zip file directly:

👉 cloudscale-seo-ai-optimizer.zip

Step 2: Install in WordPress

Go to your WordPress admin: Plugins → Add New Plugin → Upload Plugin, choose the zip file you just downloaded, then click Install Now and Activate Plugin.

WordPress plugin installation screen showing CloudScale SEO AI Optimiser

Once selected click “Install Now”:

WordPress plugin installation interface showing CloudScale SEO AI Optimiser setup process

The plugin appears in your admin sidebar under Tools → CloudScale SEO AI.

CloudScale SEO AI Optimiser WordPress plugin interface dashboard

4. Get Your Anthropic API Key

The AI features require an Anthropic API key. Getting one takes about two minutes.

Step 1 Go to console.anthropic.com and create an account. You’ll need to add a credit card, but Anthropic gives you a small credit to start with.

Step 2 Once logged in, go to Settings → API Keys and click Create Key. Give it a name like “WordPress Blog” so you can identify it later. Below is the first sceen you will likely see after signing in:

CloudScale SEO AI Optimiser WordPress plugin interface dashboard

Then you will see this page:

Claude API key creation interface in dashboard

Step 3 Copy the key. It looks like sk-ant-api03-... and you only see it once, so copy it now. Note: Once you have. copied the API key you can test it by clicking “Test Key”.

API key configuration interface for CloudScale SEO AI tool

Step 4 Back in WordPress, go to Tools → CloudScale SEO AI → Optimise SEO tab. In the AI Meta Writer card, paste your key into the API Key field and click Test Key to confirm it works. Then click Save AI Settings.

That’s it. The plugin never sends your key to any third party. It calls the Anthropic API directly from your server.

4.1 Gemini Key

Note: I dont currently have a Google Gemini account, so I have just added their link here for you to follow: https://ai.google.dev/gemini-api/docs/api-key

5. Initial Setup

With the plugin installed and your API key saved, work through these settings:

Site Identity. Fill in your site name, home title, and home description. These feed into your JSON-LD schema and OpenGraph tags. Your home description should be 140–155 characters, your homepage elevator pitch.

Person Schema. Add your name, job title, profile URL, and a link to your headshot. Add your social profiles (LinkedIn, GitHub, etc.) one per line in the SameAs field. This is what Google uses to build your author entity and connect your content to you as a person.

Features and Robots. Click the ? Explain button in the card header for a full plain English guide to every option with recommendations. For most personal tech blogs, you want OpenGraph, all three JSON-LD schemas, the sitemap enabled, and noindex on search results, 404s, attachment pages, author archives, and tag archives.

Sitemap Settings. Enable the sitemap and include Posts and Pages. Submit https://yoursite.com/sitemap.xml to Google Search Console.

Robots.txt. Review the default rules and adjust if needed. The sitemap URL is appended automatically when the sitemap is enabled.

6. Generate Your Meta Descriptions

Once your API key is saved, go to the Optimise SEO tab and scroll to the Update Posts with AI Descriptions card. The click “Load Posts”:

AI-powered WordPress SEO tool updating and optimizing blog posts automatically

You’ll see a count of your total posts, how many have descriptions, and how many are still unprocessed. Click Generate Missing to kick off a batch run. The plugin processes posts one at a time, logging each one in the terminal style display as it goes. For a site with 150–200 posts, expect it to take a few minutes.

WordPress dashboard showing AI-generated SEO optimized blog posts with meta descriptions

After the run completes, any descriptions that came out too long or too short can be cleaned up with Fix Long/Short. And if you want everything rewritten from scratch, say you’ve updated your prompt, Regenerate All will do a full pass.

For ongoing use, set up a scheduled batch in the Scheduled Batch tab. Pick which days you want it to run and the plugin will automatically process any new posts overnight. New content never goes unprocessed.

7. Performance Tab: Core Web Vitals Optimisation

The Performance tab tackles the speed problems that cost you search rankings. Google’s Core Web Vitals measure how fast your page loads and how stable it feels while loading. Three features here directly improve those scores.

Font Display Optimisation. Fonts are one of the biggest culprits for slow Largest Contentful Paint (LCP) scores. By default, browsers wait for custom fonts to download before showing any text. Your visitors stare at blank space while font files crawl across the network.

The fix is font-display: swap. This tells the browser to show text immediately using a fallback font, then swap in the custom font once it arrives. The plugin scans all your theme and plugin stylesheets for @font-face rules missing this property.

Click Scan Font Files to see which stylesheets have the problem. The plugin shows you exactly which fonts are blocking render and estimates the time savings. Click Auto Fix All to patch them. The plugin backs up each file before modifying it, so you can undo any change with one click.

For sites using Google Fonts, the savings are typically 500ms to 2 seconds off your LCP. That’s often enough to push you from amber to green in PageSpeed Insights.

Defer Render Blocking JavaScript. Scripts in your page head block rendering. The browser stops everything, downloads the script, executes it, then continues. Stack up a few plugins doing this and your page sits frozen for seconds.

The defer attribute fixes this. Deferred scripts download in parallel and execute after the HTML is parsed. The Performance tab lets you enable defer across all front end scripts with one toggle.

Some scripts break when deferred, things like jQuery that other scripts depend on, or payment widgets that need to run early. The exclusions box lets you list handles or URL fragments to skip. The plugin comes with sensible defaults for jQuery, WooCommerce, and reCAPTCHA.

HTML Minification. Every byte counts on mobile connections. The minifier strips whitespace, comments, and unnecessary characters from your HTML, CSS, and inline JavaScript before the page is sent. It’s conservative by design, it won’t break your layout, but it shaves 5 to 15 percent off page size without you changing anything.

HTTPS Mixed Content Scanner. If your site runs on HTTPS but still loads images or scripts over HTTP, browsers show security warnings and Google penalises your rankings. The scanner checks your database for http:// references to your own domain and shows you exactly where they are. One click replaces them all with https://. Fixes posts, pages, metadata, options, and comments in a single operation.

WordPress SEO plugin dashboard showing performance analytics and optimization insights

All four features are toggles. Enable what you need, test in PageSpeed Insights, and watch the numbers improve.

8. What You Get

A complete SEO setup with no monthly cost, no vendor dependency, and AI quality meta descriptions on every post. The only thing you pay for is the handful of API tokens you use, and at Haiku prices that’s less than the cost of a coffee for your entire site’s back catalogue.

I packed as much helpful hints in as a I could, so hopefully this just works for you!

Everything else, the schema markup, the sitemap, the robots.txt control, the noindex settings, is yours permanently for free.

That’s how software should work.

Andrew Baker is Chief Information Officer at Capitec Bank and writes about cloud architecture, banking technology, and enterprise software at andrewbaker.ninja.