diff --git a/lib/hexo/post.js b/lib/hexo/post.js
index 117669e5ff..e2ebea8b9b 100644
--- a/lib/hexo/post.js
+++ b/lib/hexo/post.js
@@ -2,6 +2,7 @@
 
 const assert = require('assert');
 const moment = require('moment');
+const parse5 = require('parse5');
 const Promise = require('bluebird');
 const { join, extname, basename } = require('path');
 const { magenta } = require('picocolors');
@@ -404,18 +405,50 @@ class Post {
         text: data.content,
         path: source,
         engine: data.engine,
-        toString: true,
-        onRenderEnd(content) {
-          // Replace cache data with real contents
-          data.content = cacheObj.restoreAllSwigTags(content);
-
-          // Return content after replace the placeholders
-          if (disableNunjucks) return data.content;
-
-          // Render with Nunjucks
-          return tag.render(data.content, data);
-        }
+        toString: true
       }, options);
+    }).then(content => {
+      // This function restores swig tags in `content` and render them.
+      if (disableNunjucks) {
+        // If rendering is disabled, do nothing.
+        return cacheObj.restoreAllSwigTags(content);
+      }
+      // Whether to allow async/concurrent rendering of tags within the post.
+      // Enabling it can improve performance for slow async tags, but may produce
+      // wrong results if tags within a post depend on each other.
+      const async_tags = data.async_tags || false;
+      if (!async_tags) {
+        return tag.render(cacheObj.restoreAllSwigTags(content), data);
+      }
+      // We'd like to render tags concurrently, so we split `content`
+      // by top-level HTML nodes that have swig tags into `split_content` array
+      // (nodes that don't have swig tags don't need to be split).
+      // Then we render items in `split_content` asynchronously.
+      const doc = parse5.parseFragment(content);
+      const split_content = [];
+      let current = []; // Current list of nodes to be added to split_content.
+      doc.childNodes.forEach(node => {
+        const html = parse5.serializeOuter(node);
+        const restored = cacheObj.restoreAllSwigTags(html);
+        current.push(restored);
+        if (html !== restored) {
+          // Once we encouner a node that has swig tags, merge
+          // all content we've seen so far and add to `split_content`.
+          // We don't simply add every node to `split_content`, because
+          // most nodes don't have swig tags and calling `tag.render` for
+          // all of them has significant overhead.
+          split_content.push(current.join(''));
+          current = [];
+        }
+      });
+      if (current.length) {
+        split_content.push(current.join(''));
+      }
+      // Render the tags in each top-level node asynchronously.
+      const results = split_content.map(async content => {
+        return await tag.render(content, data);
+      });
+      return Promise.all(results).then(x => x.join(''));
     }).then(content => {
       data.content = cacheObj.restoreCodeBlocks(content);
 
diff --git a/package.json b/package.json
index 09e0b12120..65b0b3020c 100644
--- a/package.json
+++ b/package.json
@@ -55,6 +55,7 @@
     "moment": "^2.29.1",
     "moment-timezone": "^0.5.34",
     "nunjucks": "^3.2.3",
+    "parse5": "^7.0.0",
     "picocolors": "^1.0.0",
     "pretty-hrtime": "^1.0.3",
     "resolve": "^1.22.0",
diff --git a/test/scripts/hexo/post.js b/test/scripts/hexo/post.js
index 8927708bf4..cadfcaee1c 100644
--- a/test/scripts/hexo/post.js
+++ b/test/scripts/hexo/post.js
@@ -902,6 +902,36 @@ describe('Post', () => {
     ].join('\n'));
   });
 
+  it('render() - multiple tags with async rendering', async () => {
+    const content = [
+      '{% blockquote %}',
+      'test1',
+      '{% quote test2 %}',
+      'test3',
+      '{% endquote %}',
+      'test4',
+      '{% endblockquote %}',
+      'ASDF',
+      '{% quote test5 %}',
+      'test6',
+      '{% endquote %}'
+    ].join('\n');
+
+    const data = await post.render(null, {
+      content,
+      async_tags: true
+    });
+    data.content.trim().should.eql([
+      '<blockquote><p>test1</p>',
+      '<blockquote><p>test3</p>',
+      '<footer><strong>test2</strong></footer></blockquote>',
+      'test4</blockquote>',
+      'ASDF',
+      '<blockquote><p>test6</p>',
+      '<footer><strong>test5</strong></footer></blockquote>'
+    ].join('\n'));
+  });
+
   it('render() - shouln\'t break curly brackets', async () => {
     hexo.config.prismjs.enable = true;
     hexo.config.highlight.enable = false;
@@ -1203,6 +1233,15 @@ describe('Post', () => {
     });
 
     data.content.trim().should.eql(`<p><code>${escapeSwigTag('{{ 1 + 1 }}')}</code> 2</p>`);
+
+    // Test that the async tags logic recognize the tags correctly.
+    const data_async = await post.render(null, {
+      content,
+      engine: 'markdown',
+      async_tags: true
+    });
+
+    data_async.content.trim().should.eql(`<p><code>${escapeSwigTag('{{ 1 + 1 }}')}</code> 2</p>`);
   });
 
   // #3543