Skip to content

Performance

Status: Audit v0.1 (2026-05-09).

1. Bundle inventory

The yarn build (or its equivalent nuxt generate) output snapshot at dist/_nuxt/ contains:

Metric Value Source
JS chunks 254 ls dist/_nuxt/*.js \| wc -l
Total JS raw 21.8 MB du -b dist/_nuxt/*.js \| awk '{sum+=$1} END {print sum}'
Total JS (concatenated and gzipped — approximation) ~5.3 MB cat dist/_nuxt/*.js \| gzip -9c \| wc -c
Total .nuxt/ build cache 62 MB du -sh .nuxt/
Total dist/ (incl. images and HTML) 78 MB du -sh dist/
polotno-bundle.js (main app) 216 KB ls -la polotno-bundle.js
polotno-bundle.css 12 KB Same

1.1 Top 10 largest JS chunks

ls -la dist/_nuxt/*.js | awk '{print $5, $9}' | sort -rn | head -10
Raw bytes Gzipped (sampled) File Likely contents
4,986,869 1,368,730 db8106d.js react-dom, konva (Polotno bundle target)
2,088,345 498,388 50ea2d1.js konva, moment
1,437,102 290,458 cc9063d.js filepond
923,358 210,595 4e81c68.js filepond + @fullcalendar/*
830,007 401,030 9a92ef4.js (TBD — investigate)
783,615 (TBD) 2d7a191.js (TBD)
721,158 (TBD) 9ec4bf1.js (TBD)
634,456 (TBD) 7cb7621.js (TBD)
484,338 (TBD) cad948e.js (TBD)
431,360 (TBD) 7427f42.js (TBD)

Gzipped values for the top 5 confirmed by:

for f in dist/_nuxt/{db8106d,50ea2d1,cc9063d,4e81c68,9a92ef4}.js; do
  gzip -9c "$f" | wc -c
done

1.2 Bundle composition findings

Finding [WC-PERF-1] (Severity: High): The single largest chunk is 5 MB raw / 1.4 MB gzipped (db8106d.js). It contains react-dom and Polotno-related code. This chunk is loaded on any editor route (/<accid>/posteditor/<id>, /<accid>/cuseditor/<id>, etc.). Editor LCP is dominated by this download.

Finding [WC-PERF-2] (Severity: High): The second-largest chunk is 2 MB / 498 KB gzipped containing konva + moment. moment is in legacy/maintenance mode (per its own maintainers — https://momentjs.com/docs/#/-project-status/). Replacing it with date-fns saves an estimated 50–100 KB gzipped. Replacing konva is harder because Polotno depends on it transitively.

Finding [WC-PERF-3] (Severity: Medium): A 1.4 MB / 290 KB gzipped chunk is dominated by filepond plus its plugins (file uploads). FilePond ships a comprehensive default UI; if used minimally, the dependency could be replaced by a thinner file-input wrapper.

Finding [WC-PERF-4] (Severity: Medium): Multiple 0.5–1 MB chunks (9a92ef4.js, 2d7a191.js, 9ec4bf1.js, etc.) suggest Vendor + per-route splits without manual chunk grouping. Webpack 4's default split-chunks behaviour is conservative; explicit cacheGroups for vendor + Polotno + FullCalendar would improve cache hit rates across deploys.

1.3 Total JS shipped on first authenticated page load

The first authenticated route (/<accid>/contentplanner) likely loads:

  • The Vue/Nuxt runtime + Vuex + bootstrap-vue + axios baseline (~150–200 KB gzipped — TBD via inspection)
  • The content-planner route chunk (@fullcalendar/* is heavy — ~210 KB gzipped per 4e81c68.js)
  • All globally-imported components, mixins, and helpers

Estimated initial gzipped JS for /contentplanner: ~700 KB–1 MB. This needs verification with a production build inspection.

For the editor pages, the figure is roughly 2 MB gzipped because the Polotno-target chunk (1.37 MB) is added.

[VERIFY-PERF-1] Run webpack-bundle-analyzer (or equivalent) against a production build and capture per-route initial JS.

2. Performance findings

ID Finding Severity
WC-PERF-1 1.4 MB gzipped editor chunk High
WC-PERF-2 moment adds ~80 KB gzipped (legacy) Medium
WC-PERF-3 FilePond ~290 KB gzipped (could be lighter) Medium
WC-PERF-4 No vendor cacheGroups in webpack config Medium
WC-PERF-5 Bootstrap loaded 3 times from CDN (5.0.2, 5.2.0-beta1, 5.2.0) High
WC-PERF-6 No image optimisation (<img> direct, no Next/NuxtImage, no responsive sources) Medium
WC-PERF-7 Google Fonts loaded with no font-display: swap directive control Low
WC-PERF-8 Many CDN third-party scripts blocking initial render Medium
WC-PERF-9 No measured Core Web Vitals; no RUM High
WC-PERF-10 Service worker caches static assets but runtimeCaching rules are minimal Medium
WC-PERF-11 lodash imported with default require (require('lodash')) — full library, no tree-shaking — in some files Medium
WC-PERF-12 jQuery loaded twice (CDN + npm dep) Medium

3. Bootstrap loaded three times

nuxt.config.js:60-82 declares:

{ src: 'https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.min.js' }
{ src: 'https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.bundle.min.js' }
{ src: 'https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js' }

Plus CSS:

'https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css'
'https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css'

Plus the npm bootstrap@5.2.0 is installed.

Three different Bootstrap versions are loaded simultaneously, partially overlapping. This is several hundred KB of redundant bytes, the styles fight for precedence (last wins), and a maintainer reading the code can't be sure which is the canonical version.

CSS loaded in nuxt.config.js → head.link:

'https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css'  // 5.0.2
'https://cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css'  // 5.2.0-beta1

Recommendation (Phase 0): consolidate to a single Bootstrap version (currently 5.3.x is stable). Existing project doc ../09-conventions.md warns against deduplicating without checking — that warning was about Bootstrap intentionally loading both js and bundle.js variants because some pages depend on order. Take the warning seriously, but the pure version proliferation is wasteful regardless.

4. Image optimisation

grep -rEn "<img " pages components 2>/dev/null \| wc -l494 image tags. None of them appear to use <picture>, srcset, or a Nuxt-image component.

There is no @nuxtjs/image module in deps. Images are served from static/, the CDN, or absolute URLs (e.g., https://...).

Finding [WC-PERF-6] (Severity: Medium): All images are <img src=...> direct, no responsive sources, no AVIF/WebP, no loading="lazy" audit. Estimated savings: 30–50% on image weight if WebP-with-fallback is adopted.

Recommendation: Phase 1 — install @nuxtjs/image (or its Nuxt 2 equivalent) and migrate the most-trafficked images first.

5. Font loading

nuxt.config.js:130-156 loads three Google Fonts CSS:

fonts.googleapis.com/css?family=Source+Sans+Pro:300,300i,400,400i,600,600i,700,700i...
fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap

The Inter declaration uses display=swap (good). The Source Sans Pro declaration does not specify display — falls back to default, which can cause FOIT.

<link rel="preconnect"> is set for fonts.googleapis.com and fonts.gstatic.com — this is good practice.

Finding [WC-PERF-7] (Severity: Low): Source Sans Pro is missing display=swap.

6. Third-party blocking scripts

nuxt.config.js → head.script declares 14 external third-party CDN scripts (plus 4 local-static scripts and 2 inline scripts; ~20 total <script> declarations). By default, Nuxt 2 puts them in <head> (parser-blocking unless they have async or defer).

Script async? defer?
Microsoft Clarity (inline) n/a n/a
FirstPromoter init (inline) n/a n/a
FirstPromoter fpr.js Yes No
Stripe.js No No
jQuery 3.6 (CDN) No No
Popper 2.11 No No
Bootstrap 5.2.0-beta1 No No
FontAwesome kit No No
Bootstrap 5.2.0-beta1 (bundle) No No
Popper 1.16 No No
Bootstrap 5.0.2 No No
Flagmeister No No
/owl.carousel.min.js (body: true) n/a No
Slick No No
jQuery validate No No
/common.js (body: true) n/a No
/universal.tag.js No No
/identity.js (body: true, defer: true) n/a Yes
Paddle.js No No

~16 of 20 are blocking renders. This is a major contributor to LCP / FCP. Many of these scripts should be defer or async-loaded, and several should be replaced with conditional, on-demand loads (Stripe.js, Paddle.js are only needed on payment pages, not globally).

Finding [WC-PERF-8] (Severity: Medium–High): ~16 third-party scripts blocking initial render. Adding async/defer or moving to per-route loading would improve FCP significantly.

7. jQuery loaded twice

{ src: 'https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js', ... }  // CDN

Plus package.json:56 has jquery: 3 as an npm dep, plus peerDependencies declares jquery: 3.

If a component imports jQuery from npm and the CDN script is also loaded, you have two jQuery instances. Bootstrap and slick-carousel typically expect one global jQuery.

Finding [WC-PERF-12] (Severity: Medium): Confirm whether any code imports jquery from npm (grep -rEn "from 'jquery'\|require\(['\"]jquery['\"]\)"); if so, either remove the npm dep (use the CDN copy) or remove the CDN copy (use the npm copy bundled). Don't run both.

8. Tree-shaking

Webpack 4 supports tree-shaking, but it requires:

  • ESM imports (✅ — Vue 2.7 + Nuxt 2 use ESM-style imports)
  • No import * as foo from ... patterns (which kill tree-shaking)
  • Production-mode build (✅ — nuxt build)

grep -rEn "import \* as " pages components store helpers 2>/dev/null \| wc -l[VERIFY-PERF-2] run this and report.

lodash is used heavily (grep -rln "from 'lodash'\|require\(['\"]lodash['\"]\)" pages components store helpers \| wc -l). The project should be using lodash-es for tree-shaking, or per-method imports (import debounce from 'lodash/debounce'). [VERIFY-PERF-3]: check actual import style.

9. Code splitting

Nuxt 2 + webpack 4 splits per route automatically. The 254 chunks suggest this is working.

There is no import('./Heavy.vue') dynamic-import pattern observed in components (grep -rEn "import\(" components 2>/dev/null \| head). Heavy modal/component code that's only used in 1-2 places could be dynamically imported to lighten the route chunk.

Recommendation (Phase 1): dynamic-import the largest modals (editPostModal.vue 3,678 lines, createContentModal.vue 2,200 lines, Chatbot.vue 2,512 lines).

10. Caching

10.1 Service worker

@nuxtjs/pwa is installed (package.json:26). static/sw.js exists with a workbox setup:

{
  "cacheId": "someli-platform-prod",
  "runtimeCaching": [
    { "urlPattern": "/_nuxt/", "handler": "CacheFirst", "method": "GET" },
    { "urlPattern": "/", "handler": "NetworkFirst", "method": "GET" }
  ]
}

Cache-first for /_nuxt/ (the chunk URLs) — good, those are content-hashed. Network-first for / (the SPA shell) — good, shell updates need to land.

Finding [WC-PERF-10] (Severity: Medium): API responses (everything outside /) are not cached by the service worker. For a content-management app with many list reads, runtime caching of safe GETs (with stale-while-revalidate) could meaningfully improve perceived performance. Consider adding rules for backend GETs.

10.2 Browser cache (Cache-Control)

nginx.conf sets no Cache-Control headers. The default is "ETag-based revalidation" (browsers conditionally fetch on each navigation). Static assets in dist/_nuxt/ are content-hashed, so they could be cached immutable, max-age=31536000 safely.

Recommendation (Phase 0): add Cache-Control: public, max-age=31536000, immutable for /_nuxt/-prefixed paths in nginx.

11. RUM and Core Web Vitals

There is no Real User Monitoring of Core Web Vitals:

  • No web-vitals library in deps (grep "web-vitals" package.json → empty).
  • No Datadog / New Relic / Sentry RUM configured.
  • Microsoft Clarity (which IS configured) provides session replay and some heatmaps, but not standard CWV reporting.

Finding [WC-PERF-9] (Severity: High): Performance is not measured. Lighthouse / PageSpeed scores are unknown without manually running them. This is the single biggest gap on the performance pillar.

Recommendation (Phase 0):

  1. Run Lighthouse against the deployed UAT site for the most-trafficked routes (/<accid>/contentplanner, /<accid>/dashboard/overview, the editor) and capture LCP/FID/CLS/INP/TBT.
  2. Add web-vitals library, send to GA / GTM (already in deps) for ongoing RUM.

12. Render performance

  • No virtualisation libraries (react-window, vue-virtual-scroller) in deps. Long lists in the my_library, content planner, and admin tables render every row.
  • Many <b-table> (bootstrap-vue) renders without pagination caps.
  • Vue.js 2's reactivity model has known performance cliffs for very large reactive arrays — frozen markers / Object.freeze are not observed in code.

Finding [WC-PERF-13] (Severity: Medium): Long lists may render poorly on lower-end devices. Spot-check the my_library (paginated at the API level — see api.js) and the content planner calendar (FullCalendar handles its own virtualisation, so this is safer).

13. Recommendations summary

Phase 0a / Phase 0

  • Run Lighthouse against UAT; record baseline.
  • Consolidate Bootstrap to a single version (delete 2 of 3 CDN entries).
  • Add Cache-Control: immutable for /_nuxt/ assets.
  • Add defer / async to non-essential CDN scripts.
  • Remove jQuery from npm deps OR remove the CDN entry — pick one.

Phase 1

  • Replace moment with date-fns or native Intl.
  • Migrate to aws-sdk v3 modular packages (already in security/deps recs).
  • Dynamic-import the largest modal components.
  • Move Stripe.js, Paddle.js to per-route conditional loading.
  • Adopt @nuxtjs/image for image optimisation.
  • Add web-vitals RUM, send to GA.

Phase 2

  • Vue 3 / Nuxt 3 migration (separate effort) brings webpack 5, modern reactivity, smaller runtime.
  • Re-evaluate FilePond vs lighter alternatives.
  • Consider replacing FullCalendar with a lighter scheduling component if calendar use is bounded.

14. Open questions

Tracked in ./verify-markers.md:

  • [VERIFY-PERF-1] Run webpack-bundle-analyzer against a production build; capture per-route initial JS
  • [VERIFY-PERF-2] import * as foo usage count
  • [VERIFY-PERF-3] lodash actual import style