The WordPress Request Lifecycle, URL to HTML

When someone visits a page on your WordPress site, there is no page. There's no HTML file named after your blog post sitting on a disk somewhere. What exists is a database full of content and a pile of PHP that, on every uncached request, assembles the page from scratch — in a strict, knowable order, in a few hundred milliseconds.

Most people who work with WordPress daily have never traced that order, and it costs them. Why does my code run too early to see the current user? Why did that redirect cause a "headers already sent" warning? Why does this URL load the wrong template? These aren't mysteries — they're lifecycle questions, and every one of them has a mechanical answer. Let's follow a single request all the way through.

Stage 1: Before WordPress exists — the web server decides

A visitor requests yoursite.com/services/consulting/. The first actor isn't WordPress; it's nginx or Apache, and it asks one question: does this path match a real file on disk?

  • If yes — an image, a CSS file, an uploaded PDF — the server hands it over directly. PHP never starts. This is why static assets are cheap and why WordPress performance conversations are really about HTML generation.
  • If no — and /services/consulting/ matches nothing on disk — a rewrite rule routes the request to a single file: index.php in the site root. Every pretty-permalinked page on your site, from the homepage to the deepest post, enters through this one door. This is the "front controller" pattern, and it's why WordPress URLs are decoupled from files.

Also in this stage: any server-level page cache (nginx FastCGI cache, Varnish, a host's edge layer). If the cache holds this URL's HTML, the response goes out now, and nothing below this line happens. Everything that follows describes the cache-miss path — which is also every logged-in request and every admin screen.

Stage 2: Bootstrap — wp-config, the database handshake, and "must-use" code

index.php is almost comically short: it defines a constant and loads wp-blog-header.php, which loads wp-load.php, which finds and executes wp-config.php. Now the environment takes shape, in order:

  1. wp-config.php runs. Database credentials, table prefix, constants like WP_DEBUG. Note the implication: code in wp-config runs before literally everything else — and has access to nothing WordPress-ish, because nothing else exists yet.
  2. wp-settings.php takes over. This file is the master boot sequence — worth reading once in your life just to see it. It loads core APIs (formatting, hooks, HTTP, etc.), connects to the database, and initializes the object cache. If a Redis drop-in exists, it's wired in here, this early, because everything downstream depends on caching.
  3. Must-use plugins load — anything in wp-content/mu-plugins/, unconditionally, before normal plugins. This is why hosts put their platform integrations there: nothing can deactivate them, and they run first.
  4. The autoloaded options query fires. One early query pulls every wp_options row flagged autoload into memory. Every request pays this cost, which is why a bloated autoload set is a sitewide tax.

Stage 3: Plugins, then theme — and the hook timeline begins

Now the order that explains half of all WordPress debugging sessions:

  1. Active plugins load, one by one, alphabetically by folder name. "Load" means their main PHP files execute top to bottom — which, in a well-built plugin, does little more than register hook callbacks for later. (Badly built plugins do real work here, at the earliest and worst possible moment.)
  2. plugins_loaded fires — the first major landmark hook. All plugins now exist and can safely talk to each other.
  3. The theme loads: first the child theme's functions.php, then the parent's. Notice: every plugin loads before any theme code. A theme can build on plugins; plugins cannot assume the theme.
  4. after_setup_theme fires — where themes register their support for features, menus, and image sizes.
  5. init fires — the great workhorse hook. Custom post types and taxonomies get registered here. The current user is now authenticated. Most "set things up" code belongs here.
  6. wp_loaded fires — WordPress is fully assembled, but no decision has been made yet about what this request is for.

Pause on that last point, because it's the lifecycle's most misunderstood fact: through all of this, WordPress hasn't looked at the URL yet. Thousands of lines of plugin code have run without anyone knowing whether this request is a blog post, the shop page, or a 404.

This is why the classic beginner bug happens — code hooked too early asking "what page is this?" gets nonsense, because the question has no answer yet. Conditional tags like is_single() are meaningless before the next stage.

Stage 4: Parsing the request — URL becomes query

Now WP::main() runs, and the URL finally matters:

  1. Rewrite matching. WordPress compares /services/consulting/ against its stored rewrite rules — an ordered list of regex patterns generated from your permalink settings and registered post types. First match wins and translates the pretty URL into query variables: effectively pagename=services/consulting. (When you "flush permalinks" to fix 404s, this list is what you're regenerating — the symptom appears here, in failed matching.)
  2. The main query runs. WordPress constructs its central WP_Query from those variables and executes real SQL against the posts table. The results — the post(s) this request is about — populate the global query object.
  3. The request is classified. Based on what came back, WordPress sets its flags: this is a single page, that's a category archive, this found nothing and is a 404. Now the conditional tags work.
  4. wp fires, then template_redirect. template_redirect is the canonical place for "look at what this request is, and maybe redirect it" logic — late enough to know everything, early enough that no output has been sent. Redirecting after output has started is what triggers the infamous "headers already sent" warning, which is a lifecycle-ordering error, not a mystery.

The famous pre_get_posts detour

One hook deserves its own callout: pre_get_posts fires just before the main query executes, and it exists so you can modify the query — exclude a category from the homepage, change posts-per-page on an archive — instead of the wasteful alternative people reach for first: letting the main query run, throwing away its results, and running a brand-new query in the template. Modify the query in flight; don't pay for it twice.

Stage 5: The template hierarchy — choosing the renderer

WordPress now knows what it's serving; the template hierarchy decides what PHP file renders it. The algorithm is a specificity cascade — for each request type, WordPress walks a list from most specific to most generic and uses the first file that exists in your theme. For a category archive of "news" it looks for, in order: category-news.php → category-{id}.php → category.php → archive.php → index.php. Every request type has such a chain, and every chain ends at index.php — the only template a theme technically must have.

Two practical consequences:

  • Overriding how one specific thing renders is usually just creating a more specific file. No hooks, no conditionals — the hierarchy does the routing.
  • When the "wrong template" loads, debug it as a hierarchy walk: what type did WordPress classify this request as (Stage 4), and what's the first existing file in that type's chain? Those two questions resolve virtually every template mystery. (Block themes follow the same hierarchy logic with .html templates; the mental model transfers.)

Stage 6: Rendering — the loop, the head, the footer

The chosen template executes, and HTML finally streams out:

  • get_header() pulls in the document head, where wp_head() fires — the hook through which SEO plugins inject meta tags and themes/plugins enqueue styles. When a plugin "adds something to every page," this is usually the door it used.
  • The Loop iterates over the main query's results — the ones fetched back in Stage 4; the template is presenting data that already exists, not fetching it. Within each iteration, the_content filter runs, and it's a big deal: shortcodes expand, blocks render, embeds resolve, lazy-loading attributes get added. Heavy shortcodes make this the slowest single step on many pages.
  • get_footer() closes the document, firing wp_footer() — where late scripts and analytics land. Then shutdown fires after the response is complete, which is where deferred work should happen.

The assembled HTML goes to the visitor — and, if a page cache is present, into the cache, so the next anonymous visitor skips everything from Stage 2 onward.

The lifecycle as a debugging map

The payoff for learning all this is that common problems become locatable:

SymptomLifecycle location
"is_page() always returns false in my code"Hooked before Stage 4 — the query hasn't run; move to template_redirect or later
"Headers already sent" on redirectRedirecting during Stage 6, after output began; move to template_redirect
Custom post type URLs 404Stage 4 rewrite matching — rules were built before the type existed; flush permalinks
Wrong template loadsStage 5 — walk the hierarchy chain for the request's classified type
Every page slow, even simple onesStages 2–3 — bootstrap cost: autoload bloat, plugins doing work at load time
Only content-heavy pages slowStage 6 — the_content filter: shortcodes, builders, embeds

Closing thought

WordPress's request lifecycle hasn't fundamentally changed in well over a decade, which makes it one of the highest-return pieces of knowledge in the ecosystem: learn it once, use it forever. The next time something behaves strangely, resist the urge to search the symptom and instead ask the lifecycle question — at what stage does this happen, and what exists at that moment? It's remarkable how many WordPress problems stop being problems the moment you know what time it is.

Photo: Sami Abdullah on Pexels