How we rewrote Highsnobiety without taking it offline
Highsnobiety moved from a full WordPress site to a React frontend and an Elixir content stack over two years, with no editorial freeze and no big-bang launch. Change data capture, a PostgreSQL document model, GraphQL, and fallback-backed edge composition made the old and new systems coexist.
- Published
- Reading time
- 22 min read
The best kind of production refactor is one that readers never notice.
Highsnobiety’s platform rewrite took roughly two years. Throughout that period, highsnobiety.com remained a high-traffic publication operating around the clock. More than sixty editors continued writing, revising, scheduling, and publishing through the familiar WordPress administration interface. There was no content freeze while a replacement database was filled, no weekend migration window, no maintenance page, and no single launch at which the entire new stack had to be correct at once.
Behind that continuity, the site changed almost completely.
The WordPress administration interface remained the editorial source of truth. MySQL row changes were captured from the replication log and sent through Kafka. Custom Elixir consumers converted WordPress’s relational rows, metadata, shortcodes, and PHP-serialized values into content components. Another Elixir service loaded those components into a document model built in PostgreSQL. A lean Elixir GraphQL API exposed that model to a server-rendered React frontend. Finally, a Cloudflare Worker allowed React to replace selected regions within live WordPress pages while retaining the old markup as a fallback.
I architected the migration and built substantial parts of the backend, API, data model, deployment path, and edge handover. It was not the work of one person: frontend, editorial-product, operations, and content-platform engineers turned the architecture into a production system and continued to refine it. The result was an engineering achievement of coordination as much as code.
The rewrite was constrained by the business, not the technology
“Rewrite WordPress in React” sounds like a frontend project. The real system included editorial workflows, custom fields, post types, shortcodes, media, taxonomies, embeds, scheduled publishing, caching, analytics, search, advertising, previews, and years of irregular content. WordPress was both the website renderer and the newsroom’s working environment.
A big-bang plan would have treated all of that as one release:
- Build the replacement CMS and frontend.
- Train the editorial team.
- Stop all writes to the old system.
- Export and transform the database.
- Import the transformed content into the replacement.
- Switch the production traffic.
- Discover the production-only assumptions under full load.
That plan optimizes the diagram rather than the business. It joins unrelated risks into one irreversible moment. A missed shortcode can corrupt an old article. A changed slug can lose search traffic. A broken preview can stop the newsroom. The new renderer and authoring interface can fail independently, yet a big-bang cutover asks both to succeed together.
We inverted the sequence. We kept the proven write path, mirrored every change into the new read path, and verified the new representations while the old site continued to serve readers. Rendering ownership moved in small pieces, and the old path could be removed only after it had become irrelevant.
This was refactoring at the scale of an entire system: preserving externally useful behavior while replacing the implementation beneath it.
The architecture had one source and two projections
WordPress remained the only place where editors wrote content during the migration. We did not build temporary dual-write forms or ask editors and application code to save into two schemas. Dual writes would have created a new consistency protocol at every WordPress mutation point, including plugins and scripts that we did not control.
Instead, MySQL’s replication stream observed changes after WordPress made them. The local production evidence identifies Maxwell as the binlog reader. It emitted row-level inserts, updates, deletes, transaction IDs, timestamps, old values, and commit markers into a Kafka topic. That detail is important: the architecture used change data capture, but the historical implementation should be named accurately.
Kafka decoupled capture from interpretation. WordPress could complete its write without waiting for the new document model or React. Consumers could stop, restart, and resume from their offsets, and the lag was measurable. Raw changes and transformed content occupied separate topics, so the parsing of WordPress semantics and the loading of PostgreSQL remained independently deployable steps.
This was continuous synchronization, not magical simultaneity. Kafka offsets and pipeline lag existed, and both were monitored. In a practical editorial sense, “perfectly in sync” meant that changes propagated continuously and deterministically without a manual export window; it did not mean that MySQL and PostgreSQL committed the same transaction at the same instant.
That distinction made the failure handling honest. Temporary lag delayed the new projection, but it did not prevent an editor from saving or take the WordPress site down.
The transaction boundary survived the row stream
WordPress content rarely changes in a single row. Publishing a post can update wp_posts, several wp_postmeta rows, term relationships, taxonomy counts, attachment metadata, and revision state within one MySQL transaction. Transforming each row as an isolated event could expose a half-built post or repeatedly overwrite a component with partial data.
The Elixir consumer buffered events that shared a transaction ID. A commit-marked event closed the buffer. Only then did the transformer process the statements, reduce multiple updates for the same content reference, and emit consolidated component events.
The core control flow was compact:
def handle_event(%{"xid" => _txid, "commit" => true} = event, txbuffer) do
handle_transaction(txbuffer ++ [event])
{:ok, :ack, []}
end
def handle_event(%{"xid" => _txid} = event, txbuffer) do
{:ok, txbuffer ++ [event]}
end
def handle_transaction([], events) do
events
|> ReduceEvents.reduce()
|> produce()
end
The output records used stable references such as wp_posts_1412981. Kafka partitioning hashed that reference, preserving a useful order for changes to the same object. Actions were mostly upsert or merge, which allowed replays and retries without requiring every consumer to know whether the projected row already existed.
The same design instinct appears in Connecting APIs with Flow, where a smaller Elixir integration pipeline preserves the failed stage and its accumulated context instead of turning the whole stream into an opaque error. Here, transaction buffers and stable references extended that approach to continuous content synchronization.
The transformer was a semantic boundary. A WordPress post was not copied into PostgreSQL as a wide row with every plugin column. It became a document with a role, a stable reference, data, ordered child components, and named relationships.
For example, an article header could be an embed, an HTML body, an external video with a still image, full-screen media, a gallery, or an annotated gallery. The source values lived across wp_posts and many wp_postmeta records. The transformer assembled them into a component shape that React understood:
{
"action": "upsert",
"role": "post-header-gallery",
"ref": "hs_post-header_1006",
"components": [
{"role": "image", "ref": "wp_posts_60000"},
{"role": "image", "ref": "wp_posts_60001"},
{"role": "image", "ref": "wp_posts_60002"}
]
}
The parser also translated WordPress shortcodes into nested components and maintained sanity checks around word and shortcode counts. This was an essential part of the migration. The infrastructure could move every row flawlessly while silently losing the meaning of an article, so structural and content-level checks had to coexist.
The existing corpus entered through the same event grammar
Live capture handles the next edit, not the previous decade of posts. We needed a bootstrap process without creating a separate set of bulk-import semantics.
The management tool selected WordPress IDs or slugs, parsed post content to discover referenced attachments, and inserted bootstrap requests for the relevant wp_posts and wp_postmeta rows into Maxwell’s bootstrap table. The bootstrap rows then traveled through the same transformer and loader as the live changes.
That choice reduced split-brain logic. One parser interpreted both the historical and current WordPress structures, and one loader applied both. Tests could use captured row events and expected transformed JSON. If the parser changed, selected content could be bootstrapped again rather than patched through one-off SQL.
The bootstrap and ongoing replication still required careful ordering because a backfill can race with newer edits. Stable source references, upserts, event offsets, and controlled bootstrap modes made that tractable; blind INSERT statements would not have done so. The operational tooling included resetting document data, requesting focused post bootstraps, and watching consumer lag. We could repair one article without rerunning the entire publication.
This is one reason the event log mattered beyond throughput: it gave the migration a repeatable unit of work.
PostgreSQL became a document database on purpose
The new content store used PostgreSQL 9.6 rather than a separate document product. Its core table was deliberately small:
CREATE TABLE documents (
id serial NOT NULL PRIMARY KEY,
role varchar(255),
ref varchar(255),
data jsonb
);
CREATE UNIQUE INDEX documents_ref_idx ON documents (ref);
CREATE INDEX documents_role_idx ON documents (role);
CREATE UNIQUE INDEX post_slug_idx
ON documents ((data->>'slug'))
WHERE role = 'post';
The role selected the component kind. The ref preserved the source identity and supported idempotent upserts. The data held role-specific fields. Ordered child IDs and named relationships represented the content tree and its cross-links.
The loader merged partial JSON updates instead of replacing an entire document:
INSERT INTO documents AS d (ref, role, data)
VALUES ($1, $2, $3)
ON CONFLICT (ref) DO UPDATE SET
role = COALESCE(EXCLUDED.role, d.role),
data = COALESCE(d.data, '{}'::jsonb) || COALESCE(EXCLUDED.data, d.data)
WHERE d.ref = EXCLUDED.ref
RETURNING id, ref, role;
The Elixir loader recursively upserted nested components, saved ordered children, and created named relationships such as post-header, featured_image, or still. Each transformed event could update only the fields represented by one WordPress row without erasing fields assembled from others.
PostgreSQL gave us transactions, indexes, constraints, recursive queries, and JSONB within the same system. Recursive common table expressions expanded the ordered document tree by slug while guarding against cyclic references. Partial indexes enforced post-slug uniqueness without pretending that every component had a slug.
This model deserves its own article because “PostgreSQL as a document database” can describe either a carefully considered hybrid or an unstructured dumping ground. Here, relational columns carried identity and topology, while JSONB carried the role-specific payload. The query patterns, constraints, and upsert behavior shaped the boundary.
I returned to that distinction in I added task metadata. The next field still got a column.. Although the later system was much smaller, the principle remained the same: JSONB could absorb externally owned variation, while explicit columns retained identity and behavior.
GraphQL insulated the frontend from projection internals
The new React team did not query documents, document_relations, and JSONB expressions. The Elixir content API presented a typed GraphQL schema: Post, Body, Image, Gallery, Quote, PostHeaderGallery, ExternalVideo, terms, authors, and component unions.
That schema was the translation layer between generic storage and the product vocabulary. The backend could retrieve a recursive tree and resolve role-specific fields while the frontend requested the exact data needed for a route or fragment. The React engineers stayed in JavaScript, Apollo, server rendering, and component design. The backend engineers stayed in Elixir, Ecto, PostgreSQL, and GraphQL execution. The contract joined the ecosystems without forcing either team into the other’s implementation.
The API was intentionally lean. The repository history shows that it was reduced from a Phoenix application to Cowboy and Plug once the full framework no longer served the boundary. It loaded the GraphQL schema, parsed and validated documents, resolved them against the content store, exposed a health endpoint, and recorded errors and query metrics.
The typed schema also surfaced incomplete parts of the migration. Unknown or invalid projected components could become placeholders rather than accidentally malformed objects. The frontend could handle an explicit absence. The schema’s evolution tracked which content capabilities had crossed from the WordPress representation into the new platform.
A later example, I made translations editable without replacing Gettext, applies the same contract-preserving approach at a smaller boundary. Callers keep one stable translation API while runtime variation remains hidden behind it, just as the React frontend depended on a content contract rather than the projection’s storage details.
GraphQL was not valuable because it removed the need to model content. It was valuable because it made the model explicit, negotiated, and visible.
React took over the page one region at a time
By the later stages of the migration, the React frontend supported server rendering, client hydration, routing, GraphQL data, a shared component library, magazine and commerce concerns, fragment builds, and full application builds. We did not, however, wait for all of that work to be complete before replacing the first pixel.
We began with bounded regions such as the header and footer. The WordPress template emitted both the old implementation and a fragment declaration. Cloudflare fetched the server-rendered React replacement, streamed it into the page when successful, and kept the WordPress block after a timeout or error.
The detailed mechanics and full Worker source are in how I built edge-side includes on Cloudflare Workers.
That edge seam created several rollout controls at once:
- WordPress feature flags enabled external fragments globally or by feature.
- Query parameters allowed deliberate checks on individual requests.
- The Worker skipped administration, uploads, includes, and other unsafe paths.
- The fragment timeout limited the damage from a failing frontend service.
- The old markup was a response-local fallback, not a remote rollback dependency.
- The React fragment server returned assets and hydration metadata for the enabled regions.
- Cache controls and tags allowed the shell and fragments to evolve independently.
After the shared chrome came larger sections and article concerns. Eventually, the React application could own an entire route rather than a region. At each stage, the old site remained both the reference implementation and the safety net.
This was the strangler pattern applied in two dimensions. The data path displaced WordPress reads by building a synchronized projection. The delivery path displaced WordPress rendering by replacing individual output regions. Neither required the immediate replacement of the editorial write path.
Zero downtime came from reversibility
No architecture can guarantee an incident-free migration merely by drawing redundant arrows. What made this zero-downtime rollout possible was the repeated decision to avoid irreversible steps.
If the capture process lagged, WordPress still accepted writes and served pages. If the transformation rejected an unusual shortcode, the old renderer still understood it. If the PostgreSQL loader retried after a deadlock, Kafka retained the work. If GraphQL lacked a component type, React did not need to own that page yet. If the fragment service failed, the edge wrote the WordPress fallback. If browser hydration broke, the server-rendered HTML still existed. If one region behaved badly, a feature flag removed that region rather than reverting the entire platform.
Every new stage could fail safely behind the old behavior.
Reversibility also focused the teams. The frontend team did not need to recreate a complete CMS before shipping the header. The backend team did not need to solve every historical post before the GraphQL contract could be exercised. The editorial team did not need to learn an unfinished tool. The operations team could watch lag, error rates, fragment fallbacks, and caches under real traffic while the blast radius remained bounded.
That operational responsibility also runs through How much cloud fits into one server?. The deployment is smaller, but it follows the same principle: an application is not finished when its code works; its startup, migrations, monitoring, recovery, and rollout behavior are part of the design.
This is why the migration could run for two years without becoming two years of frozen product work. The old and new platforms were not competing branches waiting for a merge day. They were cooperating production systems in which the old platform owned progressively less.
What “no one noticed” really means
Readers did see redesigned experiences over time. Editors did receive changes where the product required them. An “invisible refactor” does not mean visual stasis. It means that the infrastructure replacement did not impose a maintenance event or an operational rupture.
The healthy signals were mundane:
- An editor saved a post in WordPress, and the content appeared through the new stack.
- Scheduled posts continued to publish.
- Existing URLs continued to resolve.
- Old and new regions shared a single page.
- A deployment changed one ownership boundary at a time.
- A rollback meant changing a flag or using a fallback, not restoring a database.
- Engineers repaired the pipeline while the publication kept moving.
The pipeline was eventually consistent, so monitoring consumer lag was a form of business monitoring. The parser’s correctness mattered as much as message throughput. A cache hit ratio could not excuse stale editorial output. A successful HTTP status could still contain a semantically broken article. Each layer needed a metric tied to the reader and editor contract.
The absence of a dramatic cutover story is a marker of success. The business kept publishing street culture while the platform underneath changed its language, data model, API, rendering, and deployment shape.
The architecture sprint came before the implementation sprint
This result depended on deciding the migration boundaries early. WordPress would remain the write authority, and the binlog would be the integration point. Kafka would separate capture, transformation, and loading. Transformed records would use stable references and idempotent actions. PostgreSQL would store component documents, GraphQL would serve as the frontend contract, and the edge fallback would transfer rendering ownership gradually.
Those are architecture-sprint decisions. They reduce the class of future choices that engineers must make under delivery pressure. Once the boundaries held, the teams could move quickly within them.
Without that work, a rushed implementation tends to produce accidental dual writes, one-off exports, route-wide flags, coupled deployments, and a fallback that exists only in a runbook. Each shortcut appears faster for the first demonstration but becomes more expensive at the production cutover.
An architecture sprint is not months of diagrams before code. It is a short, evidence-driven effort to identify the source of truth, seams, invariants, rollback units, and observability before scaling the team. For an existing business, the key question is not “what would the ideal greenfield stack be?” It is “how can the new stack earn one responsibility without requiring the old stack to stop?”
Highsnobiety’s answer worked because the migration path was part of the product architecture, not cleanup scheduled after the product work.
Several deeper stories remain
This overview compresses several systems that each warrant their own treatment:
- The change-data-capture pipeline deserves a closer look at transaction buffering, bootstrap, parsing, partitioning, replay, and lag. For a smaller example of an Elixir pipeline designed around explicit stages and useful failure context, read Connecting APIs with Flow.
- The PostgreSQL document store warrants an examination of JSONB payloads, ordered trees, named relations, indexes, and recursive CTEs. I added task metadata. The next field still got a column. explores the same boundary between flexible JSONB payloads and explicit relational behavior in a later system.
- The Elixir GraphQL boundary shows how generic documents became typed content while the React team retained its ecosystem. I made translations editable without replacing Gettext examines a related contract-preserving design: callers retain one stable API while an additional data source remains hidden behind it.
- The architecture-sprint story explains how to design a reversible migration before delivery pressure multiplies the wrong assumptions. How much cloud fits into one server? provides an operational counterpart in which deployment, migrations, monitoring, recovery, and rollout behavior are treated as part of the application architecture.
The edge composition already has its own companion article, including the full Cloudflare Worker source, because it shows the final handover in unusually concrete form. Yet the Worker was only one detail. The main achievement was the end-to-end sequence that allowed all of those details to cooperate.
React gained its first responsibility while WordPress still owned most of the page. Editors kept working while the databases converged. Backend and frontend teams shared a contract instead of a language. We tested the new system in production before asking it to carry the whole site.
Over time, the fallback fired less often, the React regions grew, the GraphQL model covered more content, and the WordPress renderer receded. Eventually, the old path was no longer a safety net; it was obsolete.
Readers saw new designs, but they never had to wait for the platform rewrite.