Pre-rendered landing pages with Critical CSS

Hi everyone, I wanted to share a little pre-render-with-critical-css experiment I’ve been working on.

I have a web & cordova app with some sales landing pages (/, /features, /pricing, /trial, /signin, etc), and I wanted to achieve all of the following:

  • Pre-rendered and cached sales landing pages for bots & first-time visitors…
  • …including in-line critical css (I use a material design library a lot of which isn’t needed on the landing pages)
  • To reduce server load, routes behind the signin page don’t need pre-rendering - I’m happy with a “loading…” page (most logged in users will either be using the Cordova apps or the js and css bundles will be cached in the browser).

I use Vue.js, so I started with akryum:vue-ssr. This was a great start, but overly complex with data hydration, log-in cookies, none of which I really need. So I went back to the meteor vue SSR guide and the Vue SSR guide and came up with a home-grown solution which ticks all my boxes. This solution should be adaptable to other front-end layers.

My code and resulting server logs are below, but first some explanation:

  • All landing page css files are in the /client folder so are automatically bundled into the main css bundle.
  • my /server/ssr.js file does the following on startup:
    • pre-renders all the landing page routes, and stores each pre-render in an object;
    • uses critical to extract all above-the-fold css for each route;
    • combines all these css into one string and de-dupes it using clean-css (99% of the critical CSS for each landing page route is the same, so I decided to cache a single CSS for all of them rather than individual css files)
    • sets up an onPageLoad listener to serve the cached landing pages+css, or the ‘loading…’ page if not a landing page.
  • I’m deploying to Digital Ocean using MUP. I had to switch to the nabiltntn/meteord-node-with-chromium:8.11.2_dumb-init docker image as per mup issue #981

Here’s my /server/ssr.js code:

// https://github.com/meteor-vue/vue-meteor/tree/master/packages/vue-ssr
// meteor npm install --save vue-server-renderer
import { onPageLoad } from "meteor/server-render";

let sharedCss = '';
const htmlCache = {};

// https://developers.google.com/web/updates/2015/03/increasing-engagement-with-app-install-banners-in-chrome-for-android?hl=en
const cdn = Meteor.settings.public.cdn;

const links=`
<link rel="icon" type="image/png" href="${cdn}/favicon-192x192.png" sizes="192x192">
<link rel="icon" type="image/png" href="${cdn}/favicon-128x128.png" sizes="128x128">
<link rel="apple-touch-icon" href="${cdn}/favicon-128x128.png" sizes="128x128" >
<link rel="apple-touch-icon-precomposed" href="${cdn}/favicon-128x128.png" sizes="128x128" >
`;

const defaultRoute=`
<h2 style="
	width:100%;
	font-family: 'Helvetica', 'Arial', sans-serif;
	font-size: 45px;
	font-weight: 400;
	line-height: 48px;
	margin: 24px 0;
	text-align:center;
	padding-top:40vh;
	color:#666">
	Loading...
</h2>
`;

onPageLoad((sink) => {
	sink.appendToHead(links);
	const path = sink.request.url.path;
	const ssr = htmlCache[path];
	if (ssr) sink.appendToHead("<style id='ssr-css'>"+sharedCss+"</style>");
	sink.renderIntoElementById("app", ssr || defaultRoute);
});

// create pre-rendered landing pages with critical CSS.
// do this AFTER the above so that the onPageLoad handler still exists if there is a problem below...

if (Meteor.isProduction || false) { // don't bother during development, unless testing
	// fake these client-only methods for server render
	Meteor._localStorage = { getItem: () => null }
	Meteor.loggingIn = () => false;

	const Vue = require('vue');
	const { createRenderer } = require('vue-server-renderer');
	const { performance } = require('perf_hooks');
	// meteor npm install --save critical@next
	// https://github.com/addyosmani/critical/issues/359#issuecomment-470019922/package/critical
	const critical = require('critical');
	// meteor npm install --save clean-css
	const CleanCSS = require('clean-css');

	const ssrPaths = ['/', '/features', '/pricing', '/contact', '/trial', '/signin'	];
	const renderer = createRenderer();
	const rendererSync = Meteor.wrapAsync(renderer.renderToString, renderer);
	const { app, router } = require('../imports/ui/CreateApp').default();
	const syncGenerate = Meteor.wrapAsync(critical.generate, critical);
	const dimensions = '1440x900,960x600,600x960,412x732,320x568'.split(',').map(dims => {
		const [width, height] = dims.split('x');
		return {width, height}
	});
	const fs = require('fs');
	let cssPath = __meteor_bootstrap__.serverDir.replace('/server','/web.browser');
	cssPath += "/"+fs.readdirSync(cssPath).find(file => file.slice(-4)==".css");

	ssrPaths.forEach(path => {
		console.log('ssr', 'pre-rendering', path);
		let now = performance.now();
		router.push(path);
		const html = rendererSync(app);
		htmlCache[path] = html.replace(/( data-v-\w{8}|<!--.*?-->)/g, '');
		console.log('ssr', ("    "+(performance.now() - now).toFixed(3)).slice(-9), 'got html:', htmlCache[path].length, 'bytes');
		now = performance.now();
		const res = syncGenerate({
			base: '/',
			rebase: false, // needs critical@2+ https://github.com/addyosmani/critical/issues/359#issuecomment-470019922
			html,
			css: cssPath,
			dimensions,
			minify: true,
			penthouse: {forceInclude: [/mdl-layout__drawer-button/]},
		});
		if (res && res.css) sharedCss += res.css;
		console.log('ssr', ("     "+(performance.now() - now).toFixed(3)).slice(-9), 'got css:', res && res.css.length, 'bytes', path);
	});
	console.log('ssr', 'combining CSS files...');
	// add any CSS which critical misses...
	sharedCss += `
	.mdl-layout__drawer{transform: translateX(-250px); width: 240px; position: absolute; left: 0; }
	.mdl-snackbar{transform: translate(0, 80px)}
	.mdl-layout__header:not(.mdl-layout--fixed-tabs) .mdl-layout__tab-bar-container{display:none}
	`;
	const clean = new CleanCSS({level: 2}).minify(sharedCss);
	sharedCss = clean.styles;
	console.log('ssr', '  done:', JSON.stringify(clean.stats));
}

Server boot-up logs include:

01T03:37:45 ssr: pre-rendering /
01T03:37:45 ssr:    58.583 got html: 38912 bytes
01T03:37:52 ssr:  6904.129 got css: 11920 bytes
01T03:37:52 ssr: pre-rendering /features
01T03:37:52 ssr:    37.663 got html: 17604 bytes
01T03:37:55 ssr:  2819.362 got css: 14060 bytes
01T03:37:55 ssr: pre-rendering /pricing
01T03:37:55 ssr:    27.897 got html: 19970 bytes
01T03:37:57 ssr:  2244.652 got css: 13502 bytes
01T03:37:57 ssr: pre-rendering /contact
01T03:37:57 ssr:    14.506 got html: 19380 bytes
01T03:37:59 ssr:  2270.436 got css: 14920 bytes
01T03:37:59 ssr: pre-rendering /trial
01T03:37:59 ssr:    14.137 got html: 20773 bytes
01T03:38:03 ssr:  3234.234 got css: 15565 bytes
01T03:38:03 ssr: pre-rendering /signin
01T03:38:03 ssr:    17.956 got html: 18398 bytes
01T03:38:05 ssr:  2045.320 got css: 14468 bytes
01T03:38:05 ssr: combining CSS files...
01T03:38:05 ssr:   done: {"efficiency":0.7645763012968556,"minifiedSize":19878,"originalSize":84435,"timeSpent":427}
01T03:38:28 ssr:     0.024 /
01T03:39:30 ssr:     0.016 /
01T03:41:01 ssr:     0.008 /pricing
01T04:18:34 ssr:     0.037 /trial
01T04:19:38 ssr:     0.008 /
01T04:20:06 ssr:     0.006 /pricing

As you can see, the precached routes are now served in < 1ms. The main landing page request is 60.8kb (17.5kb gzippped) including pre-rendered html, critical css and even some svg!

Chrome lighthouse report from http://s.virtualinout.com (staging server of graphical re-design of my exisitng app, still a WIP…):
Screenshot%20from%202019-11-01%2017-37-36

CSS coverage of the embedded CSS = 60%:

Edited 2019-11-07: update code to be a bit more efficient and eliminate random errors about callbacks running in a fibre.

14 Likes

This bit feels like a bit of a hack:

let cssPath = __meteor_bootstrap__.serverDir.replace('/server','/web.browser');
cssPath += "/"+fs.readdirSync(cssPath).find(file => file.slice(-4)==".css");

Is there a better way of getting the path/filename of the minified css file?

This some serious pioneering right here. Your users will appreciate!:pray:

1 Like

@wildhart, good stuff. How did you move the shared css ahead of the Meteor css in your

I just inject the sharedCss into the head within onPageLoad():

	if (ssr) sink.appendToHead("<style id='ssr-css'>"+sharedCss+"</style>");

This has been live on my website since I wrote the post above and I love it! Results from webpagetest.org:

That security score is new!! // TODO Improve my security score :-O

If I am not mistaken, dynamicHead will be placed after the stylesheet in the header.

Ah yes, sorry. I’ve been using a custom boilerplate-generator for years. Initially to add the asyc attribute to the css bundle, but then to put my critical css first:

// packages/boilerplate-generator/template-web.browser.js
  return [
    '<html' + Object.keys(htmlAttributes || {}).map(
      key => template(' <%= attrName %>="<%- attrValue %>"')({
        attrName: key,
        attrValue: htmlAttributes[key],
      })
    ).join('') + '>',

    '<head>',
    headSections.join('\n'),  // CM: moved head above cssBundle
    dynamicHead,
    cssBundle,
//    (headSections.length === 1)
//     ? [cssBundle, headSections[0]].join('\n')
//     : [headSections[0], cssBundle, headSections[1]].join('\n'),
    '</head>',
    '<body>',
  ].join('\n');
1 Like

Got it. We are also thinking of doing this. I thought it was already fixed/implemented.

Neat technique on inlining css. We might also do it. Thanks

Sounds really interesting. The new Facebook.com/new took this approach to improve load times, and only load chunks of generated CSS and JS files atomically when needed - this approach reduced homepage CSS by 80% as documented here: Rebuilding our tech stack for the new Facebook.com

Thanks for the link to that article @martineboh.

It’s good that Facebook finally caught up! I’ve been using some of those techniques for years:

  • My critical CSS is only 13kB, 9% of the full CSS.
  • My logo and hero image are in-template svg. Other icons use an svg sprite via CDN
  • Thanks to Meteor I’ve been taking advantage of dynamic imports for ages.
  • I’m very conscious of depending on 3rd party packages. I regularly use https://bundlephobia.com/ to see what impact a module has. Meteor’s bundle-visualizer is awesome for this.
  • Data is only subbed when necessary for page. My landing pages don’t need any, only my logged in users subscribe to any data.

Anyone who says Meteor can’t do SEO or quick landing pages is well behind the times.

4 Likes

Seems like async does not work with stylesheets

Found this technique that we will use to async load the main stylesheet and use this inline technique for critical css

@rjdavid, thanks for the mention in your other post - nice work!

One thing I didn’t mention above, once my page has fully loaded I delete the ssr-css element from the DOM to a) to give the browser less to process and b) to help me debug any live css issues

You need to wait until the app.css is fully loaded. Since you’re also hacking boilerplate-generator you could use the same technique I use, in the boilerplate add this to the css bundle:

onload="if(media!='all')media='all';window.cssLoaded=true">`

@wildhart, thanks for this tip. On load of the stylesheet, is it also a given that it has been processed and applied? Currently away from my laptop but will also look at this latee

@rjdavid, I’m not sure to be honest. I use a 200ms setInterval to test if window.cssLoaded==true then delete the ssr-css element, and I’ve never observed a flash when there was no CSS.

Nice. Will definitely implement this :+1:

This is a great post! :tada: I didn’t fully digest it all yet, but I’m going to save it to explore/try later. :+1:

I remember you described this SSR implementation in another post for your web page content. I could see this coming in as a really useful approach for many of us.

@cloudspider wrote the Vue Guide w/ SSR details, I’m tagging him here so he might take a look at your work. He is making updates to the guide currently!

1 Like

I’ve made a small performance improvement to my ssr process - I’ve moved the critical css rendering into a child node process worker thread so that it doesn’t block the main thread. Each rendering can take 4-5 seconds which previously blocked the server just as it was restarting and all my clients were trying to reconnect.

Here's my changed code (edit: now using worker thread instead of child process)
// server/ssr.js

...

let resolver;
let worker;

async function getCriticalCssAsync(data) {
	if (!worker) {
		console.log('ssr', 'creating worker thread....');
		const { Worker } = require('worker_threads');

		const script = Assets.absoluteFilePath('ssr-process.js');
		worker = new Worker(script);

		worker.on('message', result => {
			resolver ? resolver(result) : console.log('ssr', 'from worker', result);
		});

		worker.on('exit', (code, signal) => {
			console.log('ssr', 'worker thread exited', {code, signal});
		});
	}

	return await new Promise(resolve => {
		worker.postMessage(data);

		resolver = (result) => {
			resolver = null;
			resolve(result);
		};
	});
}

...

// note that this is now wrapped in an async startup function because it awaits getCriticalCssAsync()

// don't bother during development, unless testing

if (Meteor.isProduction || false) Meteor.startup(async () => {
	// fake these client-only methods for server render
	Meteor._localStorage = { getItem: () => null }
	Meteor.loggingIn = () => false;

	const Vue = require('vue');
	const { createRenderer } = require('vue-server-renderer');
	const { performance } = require('perf_hooks');

	const ssrPaths = ['/', '/features', '/pricing', '/contact', '/trial', '/signin'	];
	const renderer = createRenderer();
	const rendererSync = Meteor.wrapAsync(renderer.renderToString, renderer);
	const { app, router } = require('../imports/ui/CreateApp').default();

	const fs = require('fs');
	let cssPath = __meteor_bootstrap__.serverDir.replace('/server','/web.browser');
	cssPath += "/"+fs.readdirSync(cssPath).find(file => file.slice(-4)==".css");

	for (const path of ssrPaths) {
		console.log('ssr', 'pre-rendering', path);
		let now = performance.now();
		router.push(path);
		const html = rendererSync(app);
		htmlCache[path] = html.replace(/( data-v-\w{8}|<!--.*?-->)/g, '');
		console.log('ssr', ("    "+(performance.now() - now).toFixed(3)).slice(-9), 'got html:', htmlCache[path].length, 'bytes');
		now = performance.now();
		const { css } = await getCriticalCssAsyc({html: htmlCache[path], cssPath});
		sharedCss += css;
		console.log('ssr', ("     "+(performance.now() - now).toFixed(3)).slice(-9), 'got css:', css?.length, 'bytes', path);
	}
	console.log('ssr', 'combining CSS files...');
	// add any CSS which critical misses...
	sharedCss += `
	.mdl-layout__drawer{transform: translateX(-250px); width: 240px; position: absolute; left: 0; }
	.mdl-snackbar{transform: translate(0, 80px)}
	.mdl-layout__header:not(.mdl-layout--fixed-tabs) .mdl-layout__tab-bar-container{display:none}
	`;
	getCriticalCssAsync({sharedCss}).then(clean => {
		sharedCss = clean.styles;
		console.log('ssr', '  done:', JSON.stringify(clean.stats));
	});
});

and the child process - note that lives in the /private folder so it doesn’t get built into the app bundle.

// private/ssr-process.js
const { parentPort } = require('worker_threads');

(async function() {
	await new Promise(resolve => {
		parentPort.on('message', async (data) => {
			if (data.html) {
				parentPort.postMessage(await getCriticalCssAsync(data));
			} else if (data.sharedCss) {
				parentPort.postMessage(cleanCss(data));
				process.exit();
			}
		});
	})
}());

// meteor npm install --save critical@next
// https://github.com/addyosmani/critical/issues/359#issuecomment-470019922/package/critical
const critical = require('../../npm/node_modules/critical');
const CleanCSS = require('../../npm/node_modules/clean-css');

// https://github.com/addyosmani/critical/issues/415
const dimensions = '320x568,412x732,600x960,960x600,1440x900'.split(',').map(dims => {
	const [width, height] = dims.split('x');
	return {width, height: height+500}
});
// const dimensions = [{width: 400, height: 1000}]; console.log('*** Forcing one dimension **');

async function getCriticalCssAsync({html, cssPath}) {
	// console.log('ssr', 'child process getting critical css...', html.length)
	return critical.generate({
		base: '/',
		rebase: false, // needs critical@2+ https://github.com/addyosmani/critical/issues/359#issuecomment-470019922
		html: html,
		css: cssPath,
		dimensions,
		minify: true,
		penthouse: {forceInclude: [/mdl-layout__drawer-button/]},
	});
}

function cleanCss({sharedCss}) {
	return new CleanCSS({level: 2}).minify(sharedCss);
}
3 Likes

Nice idea @wildhart. Any reason for using a child process rather than a worker? Or it does not matter?

[addendum]
Implemented this using worker threads. This should be a big help for us as we generate thousands of public pages on the fly before adding the page to redis

1 Like

Simple answer @rjdavid: because I didn’t know about worker threads until just now! I’ve just converted my own code to use worker threads pretty easily, although I’m not sure it matters too much to be honest.

Thanks for the suggestion! I’ll update my post with the worker thread code tomorrow - it is a little bit simpler.

1 Like