diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts
index f191c36df12..ea9b43bc324 100644
--- a/packages/runtime-core/src/component.ts
+++ b/packages/runtime-core/src/component.ts
@@ -1270,6 +1270,14 @@ export interface ComponentCustomElementInterface {
     shouldReflect?: boolean,
     shouldUpdate?: boolean,
   ): void
+  /**
+   * @internal
+   */
+  _beginPatch(): void
+  /**
+   * @internal
+   */
+  _endPatch(): void
   /**
    * @internal attached by the nested Teleport when shadowRoot is false.
    */
diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts
index a57be791a44..4e1c91b20d3 100644
--- a/packages/runtime-core/src/renderer.ts
+++ b/packages/runtime-core/src/renderer.ts
@@ -621,15 +621,27 @@ function baseCreateRenderer(
         optimized,
       )
     } else {
-      patchElement(
-        n1,
-        n2,
-        parentComponent,
-        parentSuspense,
-        namespace,
-        slotScopeIds,
-        optimized,
-      )
+      const customElement = !!(n1.el && (n1.el as VueElement)._isVueCE)
+        ? (n1.el as VueElement)
+        : null
+      try {
+        if (customElement) {
+          customElement._beginPatch()
+        }
+        patchElement(
+          n1,
+          n2,
+          parentComponent,
+          parentSuspense,
+          namespace,
+          slotScopeIds,
+          optimized,
+        )
+      } finally {
+        if (customElement) {
+          customElement._endPatch()
+        }
+      }
     }
   }
 
diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts
index c44840df5e3..fe7aef1d015 100644
--- a/packages/runtime-dom/__tests__/customElement.spec.ts
+++ b/packages/runtime-dom/__tests__/customElement.spec.ts
@@ -474,6 +474,190 @@ describe('defineCustomElement', () => {
         '<div><span>1 is number</span><span>true is boolean</span></div>',
       )
     })
+
+    test('should patch all props together', async () => {
+      let prop1Calls = 0
+      let prop2Calls = 0
+      const E = defineCustomElement({
+        props: {
+          prop1: {
+            type: String,
+            default: 'default1',
+          },
+          prop2: {
+            type: String,
+            default: 'default2',
+          },
+        },
+        data() {
+          return {
+            data1: 'defaultData1',
+            data2: 'defaultData2',
+          }
+        },
+        watch: {
+          prop1(_) {
+            prop1Calls++
+            this.data2 = this.prop2
+          },
+          prop2(_) {
+            prop2Calls++
+            this.data1 = this.prop1
+          },
+        },
+        render() {
+          return h('div', [
+            h('h1', this.prop1),
+            h('h1', this.prop2),
+            h('h2', this.data1),
+            h('h2', this.data2),
+          ])
+        },
+      })
+      customElements.define('my-watch-element', E)
+
+      render(h('my-watch-element'), container)
+      const e = container.childNodes[0] as VueElement
+      expect(e).toBeInstanceOf(E)
+      expect(e._instance).toBeTruthy()
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `<div><h1>default1</h1><h1>default2</h1><h2>defaultData1</h2><h2>defaultData2</h2></div>`,
+      )
+      expect(prop1Calls).toBe(0)
+      expect(prop2Calls).toBe(0)
+
+      // patch props
+      render(
+        h('my-watch-element', { prop1: 'newValue1', prop2: 'newValue2' }),
+        container,
+      )
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `<div><h1>newValue1</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
+      )
+      expect(prop1Calls).toBe(1)
+      expect(prop2Calls).toBe(1)
+
+      // same prop values
+      render(
+        h('my-watch-element', { prop1: 'newValue1', prop2: 'newValue2' }),
+        container,
+      )
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `<div><h1>newValue1</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
+      )
+      expect(prop1Calls).toBe(1)
+      expect(prop2Calls).toBe(1)
+
+      // update only prop1
+      render(
+        h('my-watch-element', { prop1: 'newValue3', prop2: 'newValue2' }),
+        container,
+      )
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `<div><h1>newValue3</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
+      )
+      expect(prop1Calls).toBe(2)
+      expect(prop2Calls).toBe(1)
+    })
+
+    test('should patch all props together (async)', async () => {
+      let prop1Calls = 0
+      let prop2Calls = 0
+      const E = defineCustomElement(
+        defineAsyncComponent(() =>
+          Promise.resolve(
+            defineComponent({
+              props: {
+                prop1: {
+                  type: String,
+                  default: 'default1',
+                },
+                prop2: {
+                  type: String,
+                  default: 'default2',
+                },
+              },
+              data() {
+                return {
+                  data1: 'defaultData1',
+                  data2: 'defaultData2',
+                }
+              },
+              watch: {
+                prop1(_) {
+                  prop1Calls++
+                  this.data2 = this.prop2
+                },
+                prop2(_) {
+                  prop2Calls++
+                  this.data1 = this.prop1
+                },
+              },
+              render() {
+                return h('div', [
+                  h('h1', this.prop1),
+                  h('h1', this.prop2),
+                  h('h2', this.data1),
+                  h('h2', this.data2),
+                ])
+              },
+            }),
+          ),
+        ),
+      )
+      customElements.define('my-async-watch-element', E)
+
+      render(h('my-async-watch-element'), container)
+
+      await new Promise(r => setTimeout(r))
+      const e = container.childNodes[0] as VueElement
+      expect(e).toBeInstanceOf(E)
+      expect(e._instance).toBeTruthy()
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `<div><h1>default1</h1><h1>default2</h1><h2>defaultData1</h2><h2>defaultData2</h2></div>`,
+      )
+      expect(prop1Calls).toBe(0)
+      expect(prop2Calls).toBe(0)
+
+      // patch props
+      render(
+        h('my-async-watch-element', { prop1: 'newValue1', prop2: 'newValue2' }),
+        container,
+      )
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `<div><h1>newValue1</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
+      )
+      expect(prop1Calls).toBe(1)
+      expect(prop2Calls).toBe(1)
+
+      // same prop values
+      render(
+        h('my-async-watch-element', { prop1: 'newValue1', prop2: 'newValue2' }),
+        container,
+      )
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `<div><h1>newValue1</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
+      )
+      expect(prop1Calls).toBe(1)
+      expect(prop2Calls).toBe(1)
+
+      // update only prop1
+      render(
+        h('my-async-watch-element', { prop1: 'newValue3', prop2: 'newValue2' }),
+        container,
+      )
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `<div><h1>newValue3</h1><h1>newValue2</h1><h2>newValue1</h2><h2>newValue2</h2></div>`,
+      )
+      expect(prop1Calls).toBe(2)
+      expect(prop2Calls).toBe(1)
+    })
   })
 
   describe('attrs', () => {
diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts
index edf7c431353..9cddccb4e46 100644
--- a/packages/runtime-dom/src/apiCustomElement.ts
+++ b/packages/runtime-dom/src/apiCustomElement.ts
@@ -228,6 +228,8 @@ export class VueElement
 
   private _connected = false
   private _resolved = false
+  private _patching = false
+  private _dirty = false
   private _numberProps: Record<string, true> | null = null
   private _styleChildren = new WeakSet()
   private _pendingResolve: Promise<void> | undefined
@@ -457,11 +459,11 @@ export class VueElement
     // defining getter/setters on prototype
     for (const key of declaredPropKeys.map(camelize)) {
       Object.defineProperty(this, key, {
-        get() {
+        get(this: VueElement) {
           return this._getProp(key)
         },
-        set(val) {
-          this._setProp(key, val, true, true)
+        set(this: VueElement, val) {
+          this._setProp(key, val, true, !this._patching)
         },
       })
     }
@@ -495,6 +497,7 @@ export class VueElement
     shouldUpdate = false,
   ): void {
     if (val !== this._props[key]) {
+      this._dirty = true
       if (val === REMOVAL) {
         delete this._props[key]
       } else {
@@ -670,6 +673,24 @@ export class VueElement
     this._applyStyles(comp.styles, comp)
   }
 
+  /**
+   * @internal
+   */
+  _beginPatch(): void {
+    this._patching = true
+    this._dirty = false
+  }
+
+  /**
+   * @internal
+   */
+  _endPatch(): void {
+    this._patching = false
+    if (this._dirty && this._instance) {
+      this._update()
+    }
+  }
+
   /**
    * @internal
    */