
Moving a 400-page site off Elementor: what changed, measured
I moved a 400-page B2B site off Elementor onto custom ACF blocks. Mobile Lighthouse went from 67 to 91, the homepage from 101 requests to 33, and 38 plugins became 6. How the migration was run, the WordPress traps that cost days, and the one template that got slower.

I moved a 400-page B2B site off Elementor and onto custom ACF blocks. On the site’s own code, the average mobile Lighthouse score across four key templates went from 67 to 91, the homepage dropped from 101 requests to 33, and 38 active plugins became 6. The speed came from what the new theme stopped loading, not from a caching plugin. The work that made it predictable was the boring part: counting what was really built in Elementor, converting content with scripts that ran dry first, and measuring every template, not just the homepage. One template got slower, and that is in here too.
- Count Elementor pages by each page’s own
data-elementor-id. A global header, footer or popup makes every page look like Elementor. The client’s estimate was 10 pages; the real number was 59. - Not every page deserves a block. Most layouts here were used once. Convert the common ones with a script, redesign or redirect the rest.
- Write content with WordPress’s own
serialize_block(), outside KSES, with a backup per post. The traps are in escaping, not in the design. - Load per block: CSS only where a block appears, JavaScript only when it scrolls into view. That alone took the homepage from 237 KB of CSS to 6 KB.
The numbers
Both sites ran side by side in the same environment, no page cache, Lighthouse’s simulated mobile throttling. Marketing tags (tag manager, chat, intent tracking, CRM) were blocked on both, so the comparison is the theme and plugins alone. Each figure is old → new.
| Template | Mobile score | Mobile LCP | Requests |
|---|---|---|---|
| Homepage | 65 → 92 | 8.0 s → 3.2 s | 101 → 33 |
| Partner landing page | 71 → 97 | 5.1 s → 2.4 s | 99 → 13 |
| Blog listing | 65 → 85 | 8.4 s → 3.4 s | 93 → 28 |
| Article | 67 → 89 | 6.8 s → 3.6 s | 84 → 12 |
377 → 56 KBJavaScript on the homepage, 39 files down to 6
237 → 6 KBCSS on the homepage, of which 165 KB was never used by any rule on the page
31 → 1Render-blocking files before the first paint
With every marketing tag loaded, the gap was wider: the homepage went from 40 to 95 on mobile and its LCP from 16.0 s to 2.4 s. The chat widget alone had made 68 requests on the old page. The new theme loads it, like every other tag, through the tag manager after consent and after the page has loaded.
Step 1: find out what is really built in Elementor
The first estimate was “about ten pages still use the old Elementor design”. A crawl said every page did: each one carried around 2,000 elementor-element nodes and 400 KB of HTML. Both were wrong. The site-wide header, footer and five global popups were Elementor templates, so they wrapped every page, including pages whose content had nothing to do with Elementor.
The reliable signal is the page’s own ID. Elementor renders data-elementor-id="123" for each template it outputs, and only a page built in Elementor outputs its own post ID there.
# Published pages and posts, as "ID URL"
wp post list --post_type=page,post --post_status=publish --fields=ID,url --format=csv \
| tail -n +2 | tr ',' ' ' > pages.txt
# Keep only the ones whose own ID is rendered as an Elementor template
while read -r id url; do
curl -s "$url" | grep -q "data-elementor-id=\"$id\"" && echo "$id $url"
done < pages.txt > elementor-pages.txtThat list had 59 pages. The bigger surprise was where the rest of the content lived: in an ACF Flexible Content field with about 100 section layouts, built over years. 82 were actually used. The top 13 covered about 80% of the content, and around 50 appeared exactly once.
Count usage before you design a single block. A layout used on one page is a design decision, not a component. Building a reusable block for it costs the same as building one that appears on 200 pages.
Step 2: decide page by page
Every Elementor page went into one of five buckets: convert, redesign, legal, past event (delete with a 301), or ask the client. Fifteen partner pages, seven case studies, five legal pages and two product pages were converted. Twenty-five were past event pages; they were deleted and redirected.
In hindsight I would redirect more and port less. Six new blocks were built for two bespoke pages, and porting them pixel for pixel, then patching, let the design drift. Those two pages should have been redesigned on the new system instead.
Step 3: convert with scripts that run dry first
There is no converter from Elementor or Flexible Content to blocks that you can trust on a real site, so each source got its own script. Every one ran in two modes. The dry run reported what it would write: 405 posts, 2,472 blocks, 0 unknown layouts. Only then did the live run write, and it saved the old content in a meta field on each post first, so any page could be put back in seconds. Another script turned 177 blog posts that were pasted Google Docs HTML inside Classic blocks into proper core blocks.
The design was never the hard part. Writing block markup into the database was. These are the traps that each cost me between an hour and a day:
- KSES runs in WP-CLI too. Without
unfiltered_html, it rewrites<inside block attributes intou003cand the block breaks. Remove the filters for the run. wp_update_post()strips one level of backslashes. Pass itwp_slash()ed data, or escaped JSON loses its escapes.preg_replace()reads a price like “$0” in the replacement as a backreference. Usepreg_replace_callback().- A
--inside attribute JSON closes the block comment early. Don’t build the comment by hand. - SEO plugins render the page on save. On a bulk run that can abort the whole batch; define
WP_IMPORTING. - Editors had typed HTML into plain text fields on about 70 pages. Render those fields with
wp_kses_post(), notesc_html(), or the bold text shows up as tags.
// WordPress escapes block attributes correctly (<, >, &, quotes, "--"). Let it.
$markup = implode( "\n\n", array_map(
fn ( $section ) => serialize_block( [
'blockName' => 'acf/' . $section['block'],
'attrs' => [ 'name' => 'acf/' . $section['block'], 'data' => $section['fields'], 'mode' => 'preview' ],
'innerBlocks' => [],
'innerHTML' => '',
'innerContent' => [],
] ),
$sections
) );
if ( DRY_RUN ) {
WP_CLI::log( sprintf( '%d: %d blocks', $post_id, count( $sections ) ) );
return;
}
update_post_meta( $post_id, '_pre_blocks_content', wp_slash( get_post_field( 'post_content', $post_id ) ) );
kses_remove_filters();
wp_update_post( wp_slash( [ 'ID' => $post_id, 'post_content' => $markup ] ) );After every step: request a sample of pages and expect 200s, grep the output for escaped tags like u003c and <strong, and lint the PHP. Old and new site ran side by side locally the whole time, so any page could be compared in two tabs.
Step 4: make each page load only what it uses
A page builder ships one large bundle because it cannot know which widgets a page will use. A block theme can. Most of the weight came off here.
- Per-block CSSEach block has its own small stylesheet, enqueued only when the block is on the page. Turning on separate core block assets took the stylesheet count from 77 to 19 before anything else changed.
- Per-block JavaScript, on scrollA block’s script loads when the block comes near the viewport. Blocks that change layout when they start are the exception: they load right after the page does, so nothing jumps.
- Heavy libraries out of the main bundleThe animation library (about 124 KB) and the slider (about 90 KB) load only with the blocks that use them. The header is plain JavaScript, so the main script stays at 11 KB.
- No jQueryNothing in the new theme needed it, so it stopped loading on every page.
- One high-priority imageThe real LCP element gets
fetchpriority="high"and is never lazy-loaded or faded in. On phones the hero became a smaller crop through<picture>: 334 KB down to 175 KB.
// Each block's script, loaded when the block is about to be seen
const views = import.meta.glob('../blocks/*/view.js')
const observer = new IntersectionObserver((entries) => {
for (const { isIntersecting, target } of entries) {
if (!isIntersecting) continue
observer.unobserve(target)
views[`../blocks/${target.dataset.block}/view.js`]?.().then((m) => m.default(target))
}
}, { rootMargin: '200px' })
document.querySelectorAll('[data-block]').forEach((el) => observer.observe(el))// Core block styles only for the blocks a page actually contains
add_filter( 'should_load_separate_core_block_assets', '__return_true' );The editors noticed first
Before any visitor saw the new site, the admin got fast. Every admin screen had taken 27 to 33 seconds, because a family of add-on plugins checked their licence with the vendor’s server on each request, and that server took 10 to 16 seconds to answer. With them gone, admin pages answered in under a second. Two small plugins were replaced by ACF fields on the same data, keeping 448 existing custom links and every author photo.
Editors now pick from a fixed set of blocks that look the same in the editor as on the page, instead of a canvas where any margin can be dragged. Fewer choices is the point: pages stay on the design system without anyone having to police them.
What went wrong
The blog listing got slower on desktop: 78 before, 61 after. A grid of 20 cards loaded every image at once, and a monospace font arrived late and shifted the layout (CLS 0.15). Homepage and article looked great, so it was caught only because all four templates were measured. Test every template a visitor can land on, not the one in the pitch deck.
Three more things showed up only after launch:
- Layout shift in real-user data. Chrome’s field data showed desktop CLS rising to 0.31 in the weeks after launch. Three fixes brought it to 0 in the lab, but field data takes 28 days to catch up. Watch it for a month after any launch.
- The CDN served the wrong image format. WebP versions existed, but the CDN cached one copy per URL regardless of what the browser accepted. Adding
Acceptto the cache key cut the hero from 415 KB to 159 KB. The catch: those cached variants live for a year, so a deploy without a version bump never reaches anyone. - Category cleanup sent posts to “Uncategorized”. Deleting old terms auto-assigned 140 posts there; mapping tags back to categories fixed 138 of them. The SEO plugin also kept old canonicals in its own index table until it was emptied and rebuilt.
All 258 existing redirects were kept, and every removed or renamed URL got its own 301. The SEO side of a migration is its own checklist, which I wrote up in Leaving Elementor without losing your rankings.
If I did it again
- Inventory by the page’s own IDAnd count every layout’s usage before designing blocks.
- Redirect or redesign the one-offsPort only what appears often enough to be a component.
- Screenshot every template, old and newAutomated visual comparison would have caught the design drift on bespoke pages weeks earlier.
- Measure four templates, not oneHomepage, a landing page, a listing and an article, on mobile, with tags blocked and with tags on.
- Plan a month after launchField data, CDN behaviour and caching only show up with real traffic.
The database went from 332 MB to 127 MB along the way, mostly orphaned metadata the builder had left behind, and about 14 GB of old backups and caches came out of the uploads folder, including a database dump anyone could have downloaded.
Related serviceStill on Elementor, WPBakery or Divi?Page builder migration, fixed price from $2,400: native blocks your team can still edit, every URL and redirect kept, and four templates measured before and after.See the serviceIf you want a first look before talking to anyone, the free scan on this site shows how much CSS and JavaScript your theme sends, which plugins it can see, and where your mobile LCP goes.
Questions from this article.
Here, about three weeks from the first audit to launch for roughly 400 pages, because most content moved by script. Then a month of tuning on the live site: caching, fonts and field data. A handful of bespoke one-off pages can take longer than hundreds of pages built from the same layouts.
Yes. Pages can be converted in batches while Elementor still renders the rest. Remove Elementor only after the last page is converted, because it also takes over page templates while it is active.
They get the standard WordPress editor with a fixed set of blocks that look the same in the editor as on the page. Most find it simpler than a canvas; the trade-off is less freedom to drag spacing around by hand.
Not if URLs, titles and internal links stay where they were and every removed page gets its own 301. The checklist for that is in my article on leaving Elementor without losing your rankings.
More on SEO & migrations.


Leaving Elementor without losing your rankings
A page builder migration is a move, not a redesign. Keep every URL, title and internal link where it was, and the site gets faster without a dip in traffic. Here is the checklist I run.