├── .gitignore ├── .npmignore ├── .travis.yml ├── _config.yml ├── dist └── time-to-interactive.min.js ├── index.html ├── index.js ├── package.json ├── readme.md ├── src └── index.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | webpack.config.js 3 | .travis.yml 4 | _config.yml 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /dist/time-to-interactive.min.js: -------------------------------------------------------------------------------- 1 | !function(n){var r={};function o(e){if(r[e])return r[e].exports;var t=r[e]={i:e,l:!1,exports:{}};return n[e].call(t.exports,t,t.exports,o),t.l=!0,t.exports}o.m=n,o.c=r,o.d=function(e,t,n){o.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},o.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},o.t=function(t,e){if(1&e&&(t=o(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var n=Object.create(null);if(o.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)o.d(n,r,function(e){return t[e]}.bind(null,r));return n},o.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(t,"a",t),t},o.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},o.p="",o(o.s=3)}([function(e,d,p){"use strict";(function(t){var e=p(2),n=setTimeout;function c(e){return Boolean(e&&void 0!==e.length)}function r(){}function i(e){if(!(this instanceof i))throw new TypeError("Promises must be constructed via new");if("function"!=typeof e)throw new TypeError("not a function");this._state=0,this._handled=!1,this._value=void 0,this._deferreds=[],l(e,this)}function o(n,r){for(;3===n._state;)n=n._value;0!==n._state?(n._handled=!0,i._immediateFn(function(){var e=1===n._state?r.onFulfilled:r.onRejected;if(null!==e){var t;try{t=e(n._value)}catch(e){return void u(r.promise,e)}a(r.promise,t)}else(1===n._state?a:u)(r.promise,n._value)})):n._deferreds.push(r)}function a(t,e){try{if(e===t)throw new TypeError("A promise cannot be resolved with itself.");if(e&&("object"==typeof e||"function"==typeof e)){var n=e.then;if(e instanceof i)return t._state=3,t._value=e,void s(t);if("function"==typeof n)return void l(function(e,t){return function(){e.apply(t,arguments)}}(n,e),t)}t._state=1,t._value=e,s(t)}catch(e){u(t,e)}}function u(e,t){e._state=2,e._value=t,s(e)}function s(e){2===e._state&&0===e._deferreds.length&&i._immediateFn(function(){e._handled||i._unhandledRejectionFn(e._value)});for(var t=0,n=e._deferreds.length;te||(clearTimeout(i.j),i.j=setTimeout(function(){var e=performance.timing.navigationStart,t=d(i.g,i.b);e=(window.a&&window.a.A?1e3*window.a.A().C-e:0)||performance.timing.domContentLoadedEventEnd-e;if(i.u)var n=i.u;else n=performance.timing.domContentLoadedEventEnd?(n=performance.timing).domContentLoadedEventEnd-n.navigationStart:null;var r=performance.now();null===n&&m(i,Math.max(t+5e3,r+1e3));var o=i.a;(t=r-t<5e3?null:r-(t=o.length?o[o.length-1].end:e)<5e3?null:Math.max(t,n))&&(i.s(t),clearTimeout(i.j),i.i=!1,i.c&&i.c.disconnect(),i.h&&i.h.disconnect()),m(i,performance.now()+1e3)},e-performance.now()),i.v=e)}p.prototype.getFirstConsistentlyInteractive=function(){var t=this;return new Promise(function(e){t.s=e,"complete"==document.readyState?h(t):window.addEventListener("load",function(){h(t)})})},p.prototype.m=function(e){this.f.set(e,performance.now())},p.prototype.l=function(e){this.f.delete(e)},p.prototype.B=function(){m(this,performance.now()+5e3)},n.Object.defineProperties(p.prototype,{g:{configurable:!0,enumerable:!0,get:function(){return[].concat(c(this.f.values()))}}});var v={getFirstConsistentlyInteractive:function(e){return e=e||{},"PerformanceLongTaskTiming"in window?new p(e).getFirstConsistentlyInteractive():Promise.resolve(null)}};g.exports?g.exports=v:void 0===(w=function(){return v}.apply(T,[]))||(g.exports=w)}()}).call(this,e(1))}]); -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Time To Interactive 6 | 38 | 39 | 40 | 41 | 42 |
43 | Demo uses two APIs exposed in UMD fashion:

44 | 1. getPageTTI: This is the time to interactive value reported for the entire page.
45 | 2. getReferentialTTI: This is the time to interactive value reported for an action, this could be 46 | a click action, route navigation for single page applications etc. 47 |
48 |
49 |
Time to Interactive for the page:
50 |

51 |
Time to Interacive for the action:
52 |

53 | 54 |

55 |
56 | 58 |
59 | 70 |
71 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | !function(n){var r={};function o(e){if(r[e])return r[e].exports;var t=r[e]={i:e,l:!1,exports:{}};return n[e].call(t.exports,t,t.exports,o),t.l=!0,t.exports}o.m=n,o.c=r,o.d=function(e,t,n){o.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},o.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},o.t=function(t,e){if(1&e&&(t=o(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var n=Object.create(null);if(o.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)o.d(n,r,function(e){return t[e]}.bind(null,r));return n},o.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return o.d(t,"a",t),t},o.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},o.p="",o(o.s=3)}([function(e,d,p){"use strict";(function(t){var e=p(2),n=setTimeout;function c(e){return Boolean(e&&void 0!==e.length)}function r(){}function i(e){if(!(this instanceof i))throw new TypeError("Promises must be constructed via new");if("function"!=typeof e)throw new TypeError("not a function");this._state=0,this._handled=!1,this._value=void 0,this._deferreds=[],l(e,this)}function o(n,r){for(;3===n._state;)n=n._value;0!==n._state?(n._handled=!0,i._immediateFn(function(){var e=1===n._state?r.onFulfilled:r.onRejected;if(null!==e){var t;try{t=e(n._value)}catch(e){return void u(r.promise,e)}a(r.promise,t)}else(1===n._state?a:u)(r.promise,n._value)})):n._deferreds.push(r)}function a(t,e){try{if(e===t)throw new TypeError("A promise cannot be resolved with itself.");if(e&&("object"==typeof e||"function"==typeof e)){var n=e.then;if(e instanceof i)return t._state=3,t._value=e,void s(t);if("function"==typeof n)return void l(function(e,t){return function(){e.apply(t,arguments)}}(n,e),t)}t._state=1,t._value=e,s(t)}catch(e){u(t,e)}}function u(e,t){e._state=2,e._value=t,s(e)}function s(e){2===e._state&&0===e._deferreds.length&&i._immediateFn(function(){e._handled||i._unhandledRejectionFn(e._value)});for(var t=0,n=e._deferreds.length;te||(clearTimeout(i.j),i.j=setTimeout(function(){var e=performance.timing.navigationStart,t=d(i.g,i.b);e=(window.a&&window.a.A?1e3*window.a.A().C-e:0)||performance.timing.domContentLoadedEventEnd-e;if(i.u)var n=i.u;else n=performance.timing.domContentLoadedEventEnd?(n=performance.timing).domContentLoadedEventEnd-n.navigationStart:null;var r=performance.now();null===n&&m(i,Math.max(t+5e3,r+1e3));var o=i.a;(t=r-t<5e3?null:r-(t=o.length?o[o.length-1].end:e)<5e3?null:Math.max(t,n))&&(i.s(t),clearTimeout(i.j),i.i=!1,i.c&&i.c.disconnect(),i.h&&i.h.disconnect()),m(i,performance.now()+1e3)},e-performance.now()),i.v=e)}p.prototype.getFirstConsistentlyInteractive=function(){var t=this;return new Promise(function(e){t.s=e,"complete"==document.readyState?h(t):window.addEventListener("load",function(){h(t)})})},p.prototype.m=function(e){this.f.set(e,performance.now())},p.prototype.l=function(e){this.f.delete(e)},p.prototype.B=function(){m(this,performance.now()+5e3)},n.Object.defineProperties(p.prototype,{g:{configurable:!0,enumerable:!0,get:function(){return[].concat(c(this.f.values()))}}});var v={getFirstConsistentlyInteractive:function(e){return e=e||{},"PerformanceLongTaskTiming"in window?new p(e).getFirstConsistentlyInteractive():Promise.resolve(null)}};g.exports?g.exports=v:void 0===(w=function(){return v}.apply(T,[]))||(g.exports=w)}()}).call(this,e(1))}]); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "time-to-interactive", 3 | "version": "3.0.0", 4 | "description": "Utility to report time to interactive for Real User Monitoring (RUM)", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/gauravbehere/time-to-interactive.git" 9 | }, 10 | "homepage": "https://gauravbehere.github.io/time-to-interactive/", 11 | "keywords": [ 12 | "time to interactive", 13 | "performance mertic", 14 | "RUM", 15 | "Real User Monitoring", 16 | "web performance", 17 | "cpu busy cycles" 18 | ], 19 | "scripts": { 20 | "test": "npm run build", 21 | "build": "webpack" 22 | }, 23 | "author": "Gaurav Behere, gaurav.techgeek@gmail.com", 24 | "email": "gaurav.techgeek@gmail.com", 25 | "license": "ISC", 26 | "dependencies": { 27 | "promise-polyfill": "^8.1.3", 28 | "tti-polyfill": "^0.2.2" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.5.5", 32 | "@babel/preset-env": "^7.5.5", 33 | "babel-loader": "^8.0.6", 34 | "filemanager-webpack-plugin": "^2.0.5", 35 | "uglifyjs-webpack-plugin": "^2.2.0", 36 | "webpack": "^4.39.3", 37 | "webpack-cli": "^3.3.7" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # time-to-interactive 2 | ![alt text](https://www.zauca.com/wp-content/uploads/Why-Webpage-Speed-is-Important-Factor.png) 3 | 4 | --- 5 | 6 | Utility to report time to interactive(tti) for Real User Monitoring (RUM) for web applications 7 | 8 | [![npm version](https://badge.fury.io/js/time-to-interactive.svg)](https://badge.fury.io/js/time-to-interactive) 9 | [![Build Status](https://travis-ci.org/gauravbehere/time-to-interactive.svg?branch=master)](https://travis-ci.org/gauravbehere/time-to-interactive) 10 | 11 | #### Demo 12 | https://gauravbehere.github.io/time-to-interactive/ 13 | 14 | ### APIs 15 | #### window.getPageTTI 16 | Contains a promise which on resolution reports the TTI value for the page. 17 | #### window.getReferentialTTI() 18 | Calling this on an event returns a promise which on resolution reports TTI for that event. 19 | > Referential TTI can be fired from the click event, route navigations when you render a component or an event after which you want to check how much is the time to interactive for that action 20 | 21 | ### Algorithm 22 | Basic idea is to make use of LongTask API to see if CPU was busy & report time to interactive based on 23 | the number of CPU cycles for which CPU was busy. 24 | ##### Steps: 25 | 1. First attempt is to see if PerformanceLongTaskTiming is available in window. 26 | 2. If it is available we report TTI as reported by tti-polyfill. 27 | 3. There are instances where TTI reported by tti-polyfill is less than time for loadEventEnd, thus we return max of the two. 28 | 4. If PerformanceLongTaskTiming is not available we fall back to manual polling to check if CPU is busy. 29 | 5. As we get a window of 500ms for which CPU was idle, we report TTI based on the number of cycles we had to wait to see an idle 500ms window. 30 | 6. We look for the idle window after loadEventEnd has happened so that we are also waiting for network idle state. 31 | 32 | 33 | ### Usage 34 | 35 | ##### Including the library 36 | ___ 37 | ```javascript 38 | 39 | ``` 40 | or 41 | ```javascript 42 | npm i "time-to-interactive" 43 | require('time-to-interactive'); 44 | ``` 45 | ##### Consuming the APIs 46 | ___ 47 | ```javascript 48 | window.getPageTTI.then(data => //data is the TTI value for the page) 49 | ``` 50 | ```javascript 51 | window.getReferentialTTI().then(data => //data is the TTI value for a section/component) 52 | ``` 53 | 54 | #### This metrics can be send to custom analytics integrations like Google Analytics to capture RUM data at large scale. 55 | 56 | 57 | 58 | ### Browser Compatibility 59 | As it includes polyfill for Promises & tti-polyfill, there is no browser based compatibility matrix as such. 60 | Tested with Chrome, Mozilla Firefox, Safari, Edge & IE 61 | 62 | 63 | 64 | ### A word from the author 65 | The term, time to interactive can be subjective & there could be different ways to approach it. 66 | I have tried to get a balance between how Google Chrome's lighthouse does it & how it is done by Akamai's Boomerang. Research & analysis of statstics reported as TTI suggests that, it can vary drastically between browses & platforms. As the LongTaskAPI is yet to be standardized by the browsers, we rely on manual polling to see if CPU was busy. 67 | I also recommend usage of another metric which is "first-input-delay", which is quite a standard now. 68 | 69 | #### Licence 70 | MIT 71 | 72 | Author: Gaurav Behere, gaurav.techgeek@gmail.com 73 | 74 | Feel free to raise a PR, if you see a scope of improvement :) 75 | 76 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Time to interactive 3 | * Utility to report time to interactive for Real User Monitoring (RUM) 4 | * 5 | * Usage: 6 | * window.getPageTTI.then(data => //data is the TTI value for the page) 7 | * window.getReferentialTTI().then(data => //data is the TTI value for a section/component) 8 | * 9 | * Referential TTI can be fired from the click event, route navigations when you render a component or an event 10 | * after which you want to check how much is the time to interactive for that action 11 | * 12 | * Basic idea is to make use of LongTask API to see if CPU was busy & report time to interactive based on 13 | * the number of CPU cycles for which CPU was busy. 14 | * 15 | * Algorithm: 16 | * 1. First attempt is to see if PerformanceLongTaskTiming is available in window. 17 | * 2. If it is available we report TTI as max of time to load & TTI reported by tti-polyfill. 18 | * 3. There are instances where TTI reported by tti-polyfill is less than time for loadEventEnd, thus we return a max of the two. 19 | * 4. If PerformanceLongTaskTiming is not available we fall back to manual polling to check if CPU is busy. 20 | * 5. As we get a window of 500ms for which CPU was idle, 21 | * we report TTI based on the number of cycles we had to wait to see a idle 500ms window. 22 | * 6. We look for the idle window after loadEventEnd has happened so that we are also waiting network idle state. 23 | */ 24 | 25 | import Promise from 'promise-polyfill'; 26 | const ttiPolyfill = require('tti-polyfill/tti-polyfill'); 27 | 28 | (function () { 29 | 30 | // Time threshold, if met we declare that we are not able to report TTI 31 | const TTI_THRESHOLD_TIME = 60000; 32 | 33 | /** 34 | * https://w3c.github.io/longtasks/#report-long-tasks 35 | */ 36 | const CPU_BUSY_POLLING_INTERVAL = 50; 37 | 38 | // Adding a margin to allow IE/EDGE to clamp polling 39 | const CPU_BUSY_ALLOWED_DEVIATION = 5; 40 | 41 | /** 42 | * https://developer.akamai.com/tools/boomerang/docs/BOOMR.plugins.Continuity.html#toc10__anchor 43 | */ 44 | const CPU_IDLE_TTI_WINDOW = 500; 45 | 46 | const MIN_CPU_IDLE_INTERVALS = parseInt(CPU_IDLE_TTI_WINDOW / CPU_BUSY_POLLING_INTERVAL); 47 | 48 | const timing = window.performance.timing; 49 | 50 | const getLoadTime = function () { 51 | //If load has not happened, the returned value will be negative 52 | return parseFloat(timing.loadEventEnd - timing.navigationStart).toFixed(0); 53 | } 54 | 55 | /** 56 | * Utility to check for how much time CPU is busy 57 | */ 58 | const getCPUBusyInterval = () => { 59 | let previousHit = null; 60 | let noOfCyclesCPUWasBusy = 0; 61 | let longTaskPollInterval = null; 62 | let successIntervals = 0; 63 | let busyIntervals = 0; 64 | let totalIntervals = 0; 65 | let busyTime = null; 66 | return new Promise((resolve, reject) => { 67 | const checkIfCPUIsBusy = () => { 68 | totalIntervals++; 69 | let timeDiff = window.performance.now() - previousHit; 70 | if (timeDiff > (CPU_BUSY_POLLING_INTERVAL + CPU_BUSY_ALLOWED_DEVIATION)) { 71 | successIntervals = 0; 72 | busyIntervals++; 73 | } 74 | else { 75 | //If load has not happened, getLoadTime() will be negative, we need to continue polling 76 | if (parseInt(getLoadTime()) > 0) { 77 | 78 | // If CPU busy % is <= 10%, we report success 79 | if (parseInt(busyIntervals / totalIntervals) * 100 <= 10) { 80 | successIntervals++; 81 | } 82 | else { 83 | successIntervals = 0; 84 | } 85 | 86 | if (successIntervals === MIN_CPU_IDLE_INTERVALS) { 87 | // For last successIntervals, CPU was idle & we need to subtract those intervals 88 | noOfCyclesCPUWasBusy = totalIntervals - successIntervals; 89 | clearInterval(longTaskPollInterval); 90 | busyTime = parseInt(noOfCyclesCPUWasBusy * CPU_BUSY_POLLING_INTERVAL); 91 | resolve(busyTime); 92 | } 93 | } 94 | } 95 | previousHit = window.performance.now(); 96 | } 97 | previousHit = window.performance.now(); 98 | longTaskPollInterval = setInterval(checkIfCPUIsBusy, CPU_BUSY_POLLING_INTERVAL); 99 | 100 | setTimeout(() => { 101 | clearInterval(longTaskPollInterval); 102 | if (!busyTime) reject(); 103 | }, TTI_THRESHOLD_TIME); 104 | }); 105 | } 106 | 107 | 108 | /** 109 | * adding getReferentialTTI to window, if not already present 110 | */ 111 | if (!window.getReferentialTTI) { 112 | window.getReferentialTTI = () => { 113 | return new Promise((resolve, reject) => { 114 | getCPUBusyInterval().then((busyTime) => { 115 | resolve(busyTime); 116 | }).catch(() => { 117 | reject("Could not calculate TTI within " + TTI_THRESHOLD_TIME + "ms"); 118 | }); 119 | }) 120 | }; 121 | }; 122 | 123 | 124 | /** 125 | * adding getPageTTI to window, if not already present 126 | */ 127 | if (!window.getPageTTI) { 128 | window.getPageTTI = (() => { 129 | /* 130 | * Using LongTask API if supported 131 | */ 132 | return new Promise((resolve, reject) => { 133 | if ('PerformanceLongTaskTiming' in window && PerformanceObserver in window) { 134 | !function () { 135 | let g = window.__tti = { e: [] }; 136 | g.o = new PerformanceObserver(function (l) { g.e = g.e.concat(l.getEntries()) }); 137 | g.o.observe({ entryTypes: ['longtask'] }) 138 | }(); 139 | ttiPolyfill.getFirstConsistentlyInteractive() 140 | .then((data) => { 141 | // This calculation makes sure that TTI can't be lesser than time for load(loadEventEnd) 142 | resolve(Math.max(data, getLoadTime())); 143 | }).catch(() => { 144 | reject("Could not calculate TTI within " + TTI_THRESHOLD_TIME + "ms"); 145 | });; 146 | } 147 | else { 148 | /** 149 | * Falling back to manually polling for long tasks. 150 | */ 151 | getCPUBusyInterval().then((busyTime) => { 152 | // This calculation makes sure that TTI can't be lesser than time for load(loadEventEnd) 153 | resolve(parseInt(getLoadTime()) + parseInt(busyTime)); 154 | }).catch(() => { 155 | reject("Could not calculate TTI within " + TTI_THRESHOLD_TIME + "ms"); 156 | }); 157 | } 158 | }); 159 | } 160 | )(); 161 | } 162 | })(); 163 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const deployPath = path.resolve(__dirname, './dist'); 3 | const commonJSDeployPath = path.resolve(__dirname, '.'); 4 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 5 | const deployFile = 'time-to-interactive.min.js'; 6 | const FileManagerPlugin = require('filemanager-webpack-plugin'); 7 | 8 | module.exports = { 9 | entry: './src/index.js', 10 | output: { 11 | path: deployPath, 12 | filename: deployFile 13 | }, 14 | devtool: false, 15 | mode: 'production', 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | exclude: /node_modules/, 21 | loader: 'babel-loader', 22 | query: { 23 | presets: ["@babel/preset-env"] 24 | } 25 | } 26 | ] 27 | }, 28 | optimization: { 29 | minimizer: [new UglifyJsPlugin()], 30 | }, 31 | plugins: [ 32 | new FileManagerPlugin({ 33 | onEnd: { 34 | copy: [ 35 | { source: deployPath + '/' + deployFile, destination: commonJSDeployPath + '/index.js' } 36 | ] 37 | } 38 | }) 39 | ] 40 | } 41 | --------------------------------------------------------------------------------