Why Your Custom Block’s save() Function Desyncs From Its edit() Render

In WordPress block development, the save() function defines the static HTML markup stored in post_content, while the edit() function controls the live React-rendered experience inside the block editor. A desync occurs when the markup produced by save() no longer matches the markup the editor expects from edit(). The result is the familiar “This block contains unexpected or invalid content” error, a broken block preview, or silent data loss on re-open. For small-to-mid publishing teams running custom editorial workflows, this is not a cosmetic annoyance. It is a content integrity problem that compounds across revisions, scheduled posts, and multi-author environments.

This article walks through the exact failure modes, the underlying serialization contract, and the specific fixes that prevent desyncs before they reach production. It assumes you already understand block registration basics and have shipped at least one custom block that later broke.

Developer debugging WordPress block code on a laptop screen

The Serialization Contract Between edit() and save()

WordPress stores block content as HTML comments with JSON attributes, followed by the saved markup. The block parser reads that markup back into the editor by comparing it against the output of save(). If the parser cannot reconcile the stored markup with the current save() output, the block enters recovery mode or shows a validation error.

The contract is strict: save() must return a deterministic, static HTML string. The editor then uses that string as the source of truth for block validation. Any difference between the saved markup and the expected markup triggers a desync. This includes whitespace, attribute order, class name changes, and nested component output.

Where Teams Usually Break the Contract

Most desyncs come from four specific mistakes:

  • Using dynamic values in save() that depend on runtime state, such as Date.now(), random IDs, or user-specific data.
  • Returning different markup based on editor-only conditions, like isSelected or hasSelectedInnerBlock.
  • Changing the save() output after blocks have already been saved in existing posts.
  • Using RichText or InnerBlocks incorrectly, so the saved markup does not match the editor’s internal representation.

Each of these has a specific fix, but the underlying principle is the same: save() is a pure function of block attributes. Nothing else.

Failure Mode 1: Dynamic Values in save()

Consider a block that generates a unique ID for a wrapper element. A developer might write:

save({ attributes }) {
  const id = `accordion-${Math.random().toString(36).substr(2, 9)}`;
  return 
{attributes.content}
; }

This breaks immediately. The saved markup contains one ID, but the next time the editor loads the block, save() generates a different ID. The parser sees a mismatch and flags the block as invalid.

The fix is to move any dynamic value into the edit() function only, or to store the generated value as an attribute. If the ID must be stable, generate it once during block creation and save it as an attribute:

edit({ attributes, setAttributes }) {
  if (!attributes.anchorId) {
    setAttributes({ anchorId: `accordion-${Math.random().toString(36).substr(2, 9)}` });
  }
  return 
{attributes.content}
; }, save({ attributes }) { return
{attributes.content}
; }

This keeps save() deterministic and moves the non-deterministic logic into the editor, where it belongs.

Failure Mode 2: Editor-Only Conditions in save()

Another common mistake is using editor state inside save(). Developers sometimes copy the edit() JSX into save() and forget to remove editor-only props like isSelected, className from useBlockProps, or onChange handlers.

For example:

save({ attributes, isSelected }) {
  return (
    
{attributes.content}
); }

This saves different markup depending on whether the block was selected when the post was saved. The next load will not match, and the block will break.

The fix is to strip all editor-only logic from save(). If you need a class name that reflects block state, store that state as an attribute and use it in both functions. If you need a class name only for editor styling, apply it in edit() using useBlockProps and leave save() clean.

Failure Mode 3: Changing save() After Blocks Are in Production

This is the most common desync in publishing teams. A block ships, authors create hundreds of posts with it, and then a developer changes the save() output to fix a styling issue or add a wrapper element. Every existing post now contains markup that no longer matches the new save() output.

WordPress does not automatically migrate old block markup. The block editor will show a validation error for every affected post, and authors will be prompted to attempt block recovery. In many cases, recovery fails or produces broken content.

The correct approach is to use a deprecated block definition. WordPress supports an array of deprecated save functions that allow the parser to recognize old markup and migrate it to the new format:

deprecated: [
  {
    attributes: { content: { type: 'string' } },
    save({ attributes }) {
      return 
{attributes.content}
; }, }, ],

When the editor encounters old markup, it matches it against the deprecated save(), then re-saves the block using the current save(). This preserves content and prevents validation errors.

For teams that need to migrate many posts at once, a WP-CLI script can loop through posts and re-serialize block content. But that is a separate operation and should not replace proper deprecation handling.

WordPress block editor showing a validation error on a custom block

Failure Mode 4: RichText and InnerBlocks Mismatches

RichText and InnerBlocks have their own serialization rules. If you use RichText in edit() but output plain text in save(), the parser will not be able to reconcile the two. The same applies to InnerBlocks: the saved markup must include the inner block comments exactly as the editor expects them.

A common mistake is wrapping RichText content in a custom element in save() but not in edit(), or vice versa. For example:

edit({ attributes, setAttributes }) {
  return (
     setAttributes({ content })}
    />
  );
},
save({ attributes }) {
  return 
{attributes.content}
; }

This saves the content inside a div, but the editor expects it inside a p. The block will desync on the next load.

The fix is to use the same tag name and structure in both functions. If you need a wrapper element, use it consistently:

edit({ attributes, setAttributes }) {
  return (
    
setAttributes({ content })} />
); }, save({ attributes }) { return

{attributes.content}

; }

For InnerBlocks, the same principle applies. The save() function must output the inner block comments in the exact structure that edit() renders. If you add a wrapper element in one but not the other, the block will break.

Debugging a Desync in a Live Post

When a block desyncs in a live post, the first step is to open the post in the block editor and look at the validation error. WordPress will show the expected markup and the actual markup side by side. This comparison is often enough to identify the mismatch.

If the error is not clear, inspect the raw post_content in the database. Look for the block comment and compare the saved markup to what save() currently returns. You can do this with a quick WP-CLI command:

wp post get 123 --field=post_content

Or by querying the database directly. The key is to see the exact HTML string that was saved, not the rendered output.

For more complex blocks, add temporary logging to save() to print the returned markup. Then compare that string to the stored markup character by character. Whitespace differences, self-closing tags, and attribute order all matter.

Preventing Desyncs in a Team Workflow

Small-to-mid publishing teams often have multiple developers working on the same block codebase. Without a clear process, one developer can change save() without realizing the impact on existing content.

Three practices prevent most desyncs:

  • Treat save() as a frozen contract. Once a block ships, any change to save() requires a deprecated version. This is a hard rule, not a guideline.
  • Write block fixtures. Use the @wordpress/block-editor testing utilities to create fixture files that capture the expected saved markup. Run these tests in CI to catch accidental changes.
  • Review block changes with content migration in mind. Before merging a PR that touches save(), ask: “What happens to the 500 posts that already use this block?” If the answer is not “they migrate cleanly via deprecation,” the PR is not ready.

These practices are not theoretical. They are the difference between a block that survives a year of editorial use and one that breaks every time a developer touches it.

What to Do When a Block Is Already Broken in Production

If a block has already desynced across many posts, you have three options:

  1. Add a deprecated version that matches the old markup. This is the cleanest fix. The editor will recognize the old markup and migrate it to the new format on the next save.
  2. Write a migration script. Use WP-CLI or a custom plugin to loop through posts, parse the block markup, and update it to the new format. This is more invasive but can be necessary for large content sets.
  3. Leave the old markup and handle it in the frontend. If the block is only used for display and the old markup is still valid HTML, you can write a frontend filter that handles both formats. This is a stopgap, not a long-term solution.

The worst option is to ignore the validation errors and tell authors to “just click attempt recovery.” That shifts the burden to the people least equipped to handle it and often results in lost content.

How This Fits Into a Larger Editorial Workflow

Custom blocks are not just developer toys. They are the building blocks of a publishing team’s editorial workflow. When a block desyncs, it interrupts the entire pipeline: authors cannot edit posts, editors cannot review content, and scheduled publications slip.

This is why block stability is a systems engineering problem, not a frontend problem. The save() function is a data contract. Treating it as such—with versioning, testing, and migration planning—is the only way to keep a publishing operation running smoothly.

If you are dealing with a related issue where a new WordPress site returns “Nothing Found” on the frontend, the fix often involves permalink structure or query configuration. See What to Fix First When a New WordPress Site Says Nothing Found for a step-by-step breakdown.

Team of developers reviewing WordPress block code during a code review session

FAQ

Why does my block show “This block contains unexpected or invalid content” after I update the plugin?

This error means the markup stored in post_content no longer matches the output of your current save() function. You changed the save() output without adding a deprecated version. The editor cannot reconcile the old markup with the new expected markup, so it flags the block as invalid. The fix is to add a deprecated entry that matches the old save() output.

Can I use dynamic values like Date.now() in save() if I wrap them in a useMemo hook?

No. save() is not a React component and does not run hooks. It is a pure function that receives attributes and returns static HTML. Any dynamic value in save() will produce different markup on different loads, which guarantees a desync. Move dynamic logic to edit() and store the result as an attribute.

How do I know if my block needs a deprecated version?

If you change the output of save() in any way—adding a wrapper, changing a tag name, reordering attributes, or altering whitespace—you need a deprecated version. The only exception is if the block has never been used in any published post. In a team environment, assume every block has been used somewhere and treat save() as immutable once it ships.

What is the difference between a validation error and a block recovery failure?

A validation error occurs when the stored markup does not match the expected markup from save(). Block recovery is the editor’s attempt to fix the mismatch by re-parsing the stored markup. Recovery fails when the stored markup is so different that the parser cannot map it to the current block structure. This often happens when a block’s attributes have changed significantly or when the saved markup is malformed.

Next Steps for Your Team

If you are maintaining custom blocks for a publishing team, start by auditing every block’s save() function for non-deterministic output. Then add fixture tests that capture the exact saved markup. Finally, establish a rule that any change to save() requires a deprecated version and a migration plan.

This is not a one-time fix. It is a discipline that must be part of your block development workflow. The teams that treat save() as a data contract are the ones that avoid the worst block editor failures. The teams that don’t are the ones writing emergency migration scripts at 2 a.m. before a scheduled publication.