├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── demo │ ├── demo.html │ ├── forPage.js │ ├── forSW.js │ ├── images │ │ ├── image-L.jpg │ │ ├── image-M.jpg │ │ ├── image-S.jpg │ │ ├── image-XL.jpg │ │ ├── image-XS.jpg │ │ ├── image-unknown.jpg │ │ └── image.jpg │ └── myUrlRewritingFunction.js ├── howslow-waterfall-to-stats.png └── index.md ├── examples ├── adjust-image-density │ └── page.html ├── block-fonts │ └── sw.js ├── block-third-parties │ └── sw.js ├── change-images-urls │ └── sw.js └── show-connectivity-message │ └── page.html ├── howSlowForPage.js ├── howSlowForSW.js └── package.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Fasterize 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | HowSlow is an in-browser bandwidth and RTT (roundtrip time) estimator based on a Service Worker and the Resource Timing API. When deployed on a website, it allows developers to adapt design or behavior according to the each user's bandwidth. 2 | 3 | 4 | ## How does it work? 5 | 6 | Basicaly, the Service Worker includes an algorythm that reads previous [Resource Timing](https://developer.mozilla.org/en-US/docs/Web/API/Resource_Timing_API/Using_the_Resource_Timing_API), uses them to estimate the connectivity metrics and constantly adjusts them after every newly downloaded resource. 7 | 8 | ![From network monitoring to bandwidth estimation](./docs/howslow-waterfall-to-stats.png "HowSlow : from network monitoring to bandwidth estimation") 9 | 10 | Estimated bandwidth and RTT are available both in the Service Worker and the JavaScript scope of the page to allow a large variety of behaviors. 11 | 12 | 13 | ## What is it good for? 14 | 15 | Send some love ❤️ to your slower users: 16 | + load lower quality images 17 | + avoid loading some heavy third parties such as ads, retargeting tags, AB Testing... 18 | + display a clickable thumbnail instead of a video 19 | + directly load the lowest bitrate in an adaptative bitrate video player 20 | + replace a dynamic Google Map by a static one 21 | + use an aggressive "cache then network" Service Worker strategy 22 | + switch your PWA to offline-first mode when the network gets really bad 23 | + show a "please wait" message on Ajax actions 24 | + reduce the frequency of network requests on autocomplete fields 25 | + avoid loading custom fonts 26 | + send customers to a faster competitor 😬 27 | + ... (add your ideas here) 28 | 29 | But you can also think the other way 🔃 and enhance your website quality on high speed connections: 30 | + load beautiful HD images 31 | + automatically start videos 32 | + load more results at once 33 | + ... (add your ideas here) 34 | 35 | 36 | ## How to install? 37 | 38 | ### Step 1: Load howSlowForPage.js and instantiate it 39 | 40 | Grab the howSlowForPage.js script, transpile it to ES5 (because it's written in ES6), and load it on the page whenever you want. But the sooner it's loaded, the sooner the service worker will be ready. 41 | 42 | And just after it's loaded, you need to instantiate it with the path to the service worker. 43 | 44 | ```html 45 | 46 | 47 | ``` 48 | 49 | ### Step 2: Build the service worker and serve it at the root level 50 | 51 | Grab the howSlowForSW.js script, rename it as you like (`mySW.js` in the above example) and serve it with at your website's root. You don't need to transpile the service worker's code, as Service Workers compatible browsers understand ES6. 52 | 53 | ### Step 3: Use the estimated bandwidth or RTT in your code 54 | 55 | If you need the stats in the page's scope, they're available like this: 56 | 57 | ```js 58 | if (howslow.getBandwidth() > 1000) { // Remember, bandwidth is in KBps (1 Kilo Bytes = 8 Kilo bits) 59 | videoPlayer.init(); 60 | } 61 | 62 | if (howslow.getRTT() < 50) { // Roundtrip Time is in milliseconds 63 | loadThirdParties(); 64 | } 65 | ``` 66 | 67 | If you need them in the Service Worker's scope, they are available in the same way: 68 | 69 | ```js 70 | howslow.getBandwidth() 71 | howslow.getRTT() 72 | ``` 73 | 74 | You can write your own logic at the top of the current service worker script. What you can't do is write a fetch event listener as there can be only one. But you can use some hook functions: `urlRewritingHook` and `urlBlockingHook`. More details below. 75 | 76 | If you want to keep your service worker's logic separated from howslow, you can use the importScripts() method. But that's one more request before the Service Workers is available. 77 | 78 | ```js 79 | self.importScripts('howSlowForSW.js'); 80 | 81 | // ... and here is your own code 82 | ``` 83 | 84 | ### The urlRewritingHook 85 | 86 | Use this hook to rewrite the URL before the service workers sends the request. The function should return the new URL or `null` if no change is needed. 87 | 88 | Here is an example that adds an `-hd` suffix to images on fast connections: 89 | 90 | ```js 91 | function urlRewritingHook(url) { 92 | 93 | // Let's intercept every editorial image call 94 | const regexp = /images\/editorial\/(.*)\.jpg$/; 95 | const execResult = regexp.exec(url); 96 | 97 | if (execResult !== null && howslow.getBandwidth() > 1000) { 98 | // Add an "-hd" suffix to the image name 99 | return '/images/editorial/' + execResult[1] + '-hd.jpg'; 100 | } 101 | 102 | // Respond null for the default behavior 103 | return null; 104 | } 105 | 106 | // ... and here is the rest of the howSlowForSW.js script 107 | ``` 108 | 109 | ### The urlBlockingHook 110 | 111 | Use this hook to cancel the request before it's sent to network. Returning `true` will block the request, returning `false` will let it go. 112 | 113 | Here is an example that blocks a third party script on slow connections: 114 | 115 | ```js 116 | function urlBlockingHook(url) { 117 | return (url === 'https://thirdparty.com/tracker.js' && howslow.getBandwidth() < 50); 118 | } 119 | ``` 120 | 121 | ## Some coding examples 122 | 123 | [Block a custom font on slow bandwidth](./examples/block-fonts/sw.js) 124 | 125 | [Adjust density of responsive images according to bandwidth](./examples/adjust-image-density/page.html) 126 | 127 | [Rewrite image URLs to add an "-hd" suffix](./examples/change-images-urls/sw.js) 128 | 129 | [Block a third party script](./examples/block-third-parties/sw.js) 130 | 131 | [Display a "Slow connection detected" message](./examples/show-connectivity-message/page.html) 132 | 133 | 134 | ## How are the averages calculated? 135 | 136 | The algorithm uses "time weighted" averages: the most recent data has more weight than then old one. It is meant to provide a good compromise between reactivity and stability and to avoid the yo-yo effect. 137 | 138 | 139 | ## Will it work on the first page load? 140 | 141 | The first time it's called, the Service Worker needs time to instantiate and initialize itself. For that reason, it only gets available after a few seconds and you might miss entirely the first page load. But it'll be ready for the next user action. 142 | 143 | For returning visitors, HowSlow will first serve the last known values and adjust to the new bandwidth ASAP, in case it has changed in between. 144 | 145 | 146 | ## Ok, great. But what's the difference with the Network Information API? 147 | 148 | The [Network Information API](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation) is not yet mature and is only able to provide bandwidth/RTT estimations on Chrome Desktop. Smartphones are not supported. 149 | 150 | 151 | ## Compatibility 152 | 153 | It's compatible with the latest versions of Chrome, Firefox and Safari. Unfortunately, Edge (v17) is not compatible. We're looking for a workaround. 154 | 155 | However, Service Workers are quite unpredictable and you should not rely on this tool for important tasks. Use it for **progressive enhancement**. 156 | 157 | 158 | ## Demo 159 | 160 | Demo page: https://fasterize.github.io/HowSlow/demo/demo.html 161 | 162 | Mirror: https://gmetais.github.io/howslow/demo/demo.html 163 | 164 | 165 | ## Authors 166 | 167 | The Fasterize team. [Fasterize](https://www.fasterize.com) is a frontend optimization platform that makes your website super-fast, super-easily. 168 | 169 | -------------------------------------------------------------------------------- /docs/demo/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | HowSlow demo page 4 | 5 | 21 | 22 | 23 |

This page reloads indefinitely the same image. But a Service Worker intercepts the requests and chooses what images it wants, based on a bandwidth estimation.

24 |

Estimated bandwidth: unknown KBps
Estimated RTT: unknown ms

25 | 26 | 27 | 28 | 29 |
30 | 31 | 88 | 89 | 90 | 93 | 94 | -------------------------------------------------------------------------------- /docs/demo/forPage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 4 | 5 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 6 | 7 | var HowSlowForPage = function () { 8 | function HowSlowForPage(swPath) { 9 | var _this = this; 10 | 11 | _classCallCheck(this, HowSlowForPage); 12 | 13 | this.bandwidth = undefined; 14 | this.rtt = undefined; 15 | this.firstRequestSent = false; 16 | this.navigationStart = 0; 17 | 18 | this.initSW(swPath).then(function () { 19 | return _this.listenToSW(); 20 | }); 21 | 22 | this.storageIO(); 23 | } 24 | 25 | _createClass(HowSlowForPage, [{ 26 | key: 'getBandwidth', 27 | value: function getBandwidth() { 28 | return this.bandwidth; 29 | } 30 | }, { 31 | key: 'getRTT', 32 | value: function getRTT() { 33 | return this.rtt; 34 | } 35 | 36 | // Initializes the Service Worker, or at least tries 37 | 38 | }, { 39 | key: 'initSW', 40 | value: function initSW(swPath) { 41 | return new Promise(function (resolve, reject) { 42 | if (!('serviceWorker' in window.navigator)) { 43 | // No browser support 44 | reject('Service Workers not supported'); 45 | } 46 | 47 | if (window.navigator.serviceWorker.controller) { 48 | if (window.navigator.serviceWorker.controller.scriptURL.indexOf(swPath) >= 0) { 49 | // The service worker is already active 50 | resolve(); 51 | } else { 52 | reject('Giving up. Service Worker conflict with: ' + window.navigator.serviceWorker.controller.scriptURL); 53 | } 54 | } else { 55 | window.navigator.serviceWorker.register(swPath).then(window.navigator.serviceWorker.ready).then(function (serviceWorkerRegistration) { 56 | // The service worker is registered and should even be ready now 57 | resolve(); 58 | }); 59 | } 60 | }); 61 | } 62 | 63 | // Geting ready to receive messages from the Service Worker 64 | 65 | }, { 66 | key: 'listenToSW', 67 | value: function listenToSW() { 68 | var _this2 = this; 69 | 70 | window.navigator.serviceWorker.onmessage = function (event) { 71 | if (event.data.command === 'timingsPlz') { 72 | // The Service Workers asks for resource timings 73 | var timings = _this2.readLatestResourceTimings(); 74 | 75 | if (timings.length > 0) { 76 | _this2.sendResourceTimings(timings); 77 | } 78 | } else if (event.data.command === 'stats') { 79 | // The Service Workers sends the latest stats 80 | _this2.bandwidth = event.data.bandwidth; 81 | _this2.rtt = event.data.rtt; 82 | } 83 | }; 84 | } 85 | 86 | // Gathers the latest ResourceTimings and sends them to SW 87 | 88 | }, { 89 | key: 'sendResourceTimings', 90 | value: function sendResourceTimings(simplifiedTimings) { 91 | try { 92 | navigator.serviceWorker.controller.postMessage({ 93 | 'command': 'eatThat', 94 | 'timings': simplifiedTimings 95 | }); 96 | } catch (error) {} 97 | } 98 | 99 | // Gathers the ResourceTimings from the API 100 | 101 | }, { 102 | key: 'readLatestResourceTimings', 103 | value: function readLatestResourceTimings() { 104 | var _this3 = this; 105 | 106 | // Not compatible browsers 107 | if (!window.performance || !window.performance.getEntriesByType || !window.performance.timing) { 108 | return []; 109 | } 110 | 111 | var timings = []; 112 | 113 | if (!this.firstRequestSent) { 114 | 115 | // Save this for later 116 | this.navigationStart = window.performance.timing.navigationStart; 117 | 118 | // The first HTML resource is as intersting as the others... maybe even more! 119 | timings.push({ 120 | name: window.location.href, 121 | transferSize: window.performance.timing.transferSize, 122 | domainLookupStart: window.performance.timing.domainLookupStart, 123 | domainLookupEnd: window.performance.timing.domainLookupEnd, 124 | connectStart: window.performance.timing.connectStart, 125 | connectEnd: window.performance.timing.connectEnd, 126 | requestStart: window.performance.timing.requestStart, 127 | responseStart: window.performance.timing.responseStart, 128 | responseEnd: window.performance.timing.responseEnd 129 | }); 130 | 131 | this.firstRequestSent = true; 132 | } 133 | 134 | window.performance.getEntriesByType('resource').forEach(function (timing) { 135 | timings.push({ 136 | name: timing.name, 137 | transferSize: timing.transferSize, 138 | domainLookupStart: Math.round(_this3.navigationStart + timing.domainLookupStart), 139 | domainLookupEnd: Math.round(_this3.navigationStart + timing.domainLookupEnd), 140 | connectStart: Math.round(_this3.navigationStart + timing.connectStart), 141 | connectEnd: Math.round(_this3.navigationStart + timing.connectEnd), 142 | secureConnectionStart: Math.round(_this3.navigationStart + timing.secureConnectionStart), 143 | requestStart: Math.round(_this3.navigationStart + timing.requestStart), 144 | responseStart: Math.round(_this3.navigationStart + timing.responseStart), 145 | responseEnd: Math.round(_this3.navigationStart + timing.responseEnd) 146 | }); 147 | }); 148 | 149 | // Now lets clear resourceTimings 150 | window.performance.clearResourceTimings(); 151 | window.performance.setResourceTimingBufferSize(200); 152 | 153 | // TODO: add an option to avoid clearing ResourceTimings... 154 | // ... some other scripts might need them! 155 | 156 | return timings; 157 | } 158 | 159 | // On the SW's side, stats are saved in IndexedDB. Here we have access to LocalStorage. 160 | 161 | }, { 162 | key: 'storageIO', 163 | value: function storageIO() { 164 | var _this4 = this; 165 | 166 | // When leaving the page, save stats into LocalStorage for faster ignition 167 | window.addEventListener('unload', function () { 168 | if (_this4.bandwidth || _this4.rtt) { 169 | window.localStorage.setItem('howslow', _this4.bandwidth + ',' + _this4.rtt); 170 | } 171 | }); 172 | 173 | // And when arriving on the page, retrieve stats 174 | var stats = window.localStorage.getItem('howslow'); 175 | if (stats) { 176 | stats = stats.split(','); 177 | this.bandwidth = stats[0] || undefined; 178 | this.rtt = stats[1] || undefined; 179 | } 180 | } 181 | }]); 182 | 183 | return HowSlowForPage; 184 | }(); 185 | -------------------------------------------------------------------------------- /docs/demo/forSW.js: -------------------------------------------------------------------------------- 1 | function urlRewritingHook(url) { 2 | 3 | // This is some demo code. Adapt to your needs. 4 | 5 | // Let's intercept every call to image.jpg 6 | const regexp = /images\/image\.jpg\?timestamp=(.*)$/; 7 | const execResult = regexp.exec(url); 8 | 9 | if (execResult !== null) { 10 | 11 | // ... and choose the right image! 12 | var bandwidth = howslow.getBandwidth(); 13 | 14 | if (bandwidth > 4000) { 15 | return 'images/image-XL.jpg?timestamp=' + execResult[1]; 16 | } else if (bandwidth > 1000) { 17 | return 'images/image-L.jpg?timestamp=' + execResult[1]; 18 | } else if (bandwidth > 200) { 19 | return 'images/image-M.jpg?timestamp=' + execResult[1]; 20 | } else if (bandwidth > 50) { 21 | return 'images/image-S.jpg?timestamp=' + execResult[1]; 22 | } else if (bandwidth > 10) { 23 | return 'images/image-XS.jpg?timestamp=' + execResult[1]; 24 | } else { 25 | return 'images/image-unknown.jpg?timestamp=' + execResult[1]; 26 | } 27 | } 28 | 29 | // Return null for urls you don't want to change 30 | return null; 31 | } 32 | 33 | /* ----- ↑ Write your own service workers rules above this line ↑ ----- */ 34 | /* ----- ↓ Change below this line at your own risks ↓ -------------------------------- */ 35 | 36 | // Service Worker initialization 37 | self.addEventListener('install', () => { 38 | self.skipWaiting(); // Activate worker immediately 39 | }); 40 | 41 | self.addEventListener('activate', () => { 42 | if (self.clients && self.clients.claim) { 43 | // Make it available immediately 44 | self.clients.claim().then(() => { 45 | // The service worker is ready to work 46 | 47 | // The attached pages might already have some resource timings available. 48 | // Let's ask! 49 | howslow.askTimingsToClients(); 50 | }); 51 | } 52 | }); 53 | 54 | // Intercept requests 55 | self.addEventListener('fetch', (event) => { 56 | 57 | if (typeof urlBlockingHook === 'function' && urlBlockingHook(event.request.url) === true) { 58 | event.respondWith(new Response('', { 59 | status: 446, 60 | statusText: 'Blocked by Service Worker' 61 | })); 62 | return; 63 | } 64 | 65 | let modifiedUrl = (typeof urlRewritingHook === 'function') ? urlRewritingHook(event.request.url) : null; 66 | let options = {}; 67 | 68 | if (modifiedUrl) { 69 | // Add credentials to the request otherwise the fetch method opens a new connection 70 | options.credentials = 'include'; 71 | } 72 | 73 | event.respondWith( 74 | fetch((modifiedUrl || event.request), options) 75 | .then(function(response) { 76 | // Save the content-length header 77 | howslow.addContentLength(response.url, response); 78 | return response; 79 | }) 80 | ); 81 | }); 82 | 83 | 84 | class HowSlowForSW { 85 | 86 | constructor() { 87 | this.allTimings = []; 88 | this.allContentLengths = []; 89 | this.allIntervals = []; 90 | this.INTERVAL_DURATION = 25; // in milliseconds 91 | 92 | // That's our supposed service worker initialization time 93 | // TODO: replace by the reference time used inside the SW's Resource Timings 94 | // (I have no idea how to find it) 95 | this.epoch = Date.now(); 96 | 97 | // Start the ticker 98 | this.tick(); 99 | 100 | // Listen to the broadcast responses 101 | self.addEventListener('message', (event) => { 102 | if (event.data.command === 'eatThat') { 103 | // Some new timings just arrived from a page 104 | event.data.timings.forEach((timing) => { 105 | this.addOneTiming(timing) 106 | }); 107 | } 108 | }); 109 | 110 | this.initDatabase(); 111 | } 112 | 113 | // Every second: 114 | tick() { 115 | // Do that... 116 | this.refreshStats(); 117 | this.sendStatsToClients(); 118 | 119 | // ... and repeat 120 | setTimeout(() => { 121 | this.tick(); 122 | }, 1000); 123 | } 124 | 125 | getBandwidth() { 126 | if (this.bandwidth) { 127 | return this.bandwidth; 128 | } 129 | 130 | // If we couldn't estimate bandwidth yet, but we've got a record in database 131 | // We serve the saved bandwidth 132 | if (!this.connectionTypeFromDatabase 133 | || !self.navigator.connection 134 | || !self.navigator.connection.type 135 | || this.connectionTypeFromDatabase === self.navigator.connection.type) { 136 | 137 | return this.bandwidthFromDatabase; 138 | } 139 | 140 | return undefined; 141 | } 142 | 143 | getRTT() { 144 | if (this.rtt) { 145 | return this.rtt; 146 | } 147 | 148 | // If we couldn't estimate bandwidth yet, but we've got a record in database 149 | // We serve the saved bandwidth 150 | if (!this.connectionTypeFromDatabase 151 | || !self.navigator.connection 152 | || !self.navigator.connection.type 153 | || this.connectionTypeFromDatabase === self.navigator.connection.type) { 154 | 155 | return this.rttFromDatabase; 156 | } 157 | 158 | return undefined; 159 | } 160 | 161 | // Updates bandwidth & rtt 162 | refreshStats() { 163 | 164 | // Update the data from resource timings 165 | this.refreshTimings(); 166 | 167 | // Use the data to estimate bandwidth 168 | this.bandwidth = this.estimateBandwidth(); 169 | this.rtt = this.estimateRTT(); 170 | 171 | // If the bandwith or the RTT were correctly estimated, 172 | // we save them to database 173 | if (this.bandwidth || this.rtt) { 174 | this.saveStats(); 175 | } 176 | } 177 | 178 | // Collects the latest resource timings 179 | refreshTimings() { 180 | 181 | if (self.performance && self.performance.getEntriesByType) { 182 | // If the Service Worker has access to the Resource Timing API, 183 | // It's easy, we just read it. 184 | 185 | self.performance.getEntriesByType('resource').forEach((timing) => { 186 | this.addOneTiming(this.simplifyTimingObject(timing)); 187 | }); 188 | 189 | // Then we empty the history 190 | self.performance.clearResourceTimings(); 191 | 192 | // "The clearResourceTimings() method removes all performance entries [...] and sets 193 | // the size of the performance data buffer to zero. To set the size of the browser's 194 | // performance data buffer, use the Performance.setResourceTimingBufferSize() method." 195 | self.performance.setResourceTimingBufferSize(200); 196 | } 197 | 198 | // TODO : the "else" part, when the Service Workers doesn't have access to 199 | // the Resource Timing API (Microsoft Edge) 200 | } 201 | 202 | // Sends a request to all clients for their resource timings. 203 | askTimingsToClients() { 204 | this.sendMessageToAllClients({ 205 | command: 'timingsPlz' 206 | }); 207 | } 208 | 209 | // Send bandwidth and RTT to all clients 210 | sendStatsToClients() { 211 | this.sendMessageToAllClients({ 212 | command: 'stats', 213 | bandwidth: this.getBandwidth(), 214 | rtt: this.getRTT() 215 | }); 216 | } 217 | 218 | // Sends a message to all clients 219 | sendMessageToAllClients(json) { 220 | self.clients.matchAll() 221 | .then((clients) => { 222 | clients.forEach((client) => { 223 | client.postMessage(json); 224 | }); 225 | }); 226 | } 227 | 228 | // Saves one timing in the allTimings list 229 | // only if it doesn't look like it comes from the browser's cache 230 | addOneTiming(timing) { 231 | 232 | // If we don't have the transfer size (Safari & Edge don't provide it) 233 | // than let's try to read it from the Content-Length headers. 234 | if (!timing.transferSize) { 235 | timing.transferSize = this.findContentLength(timing.name); 236 | } 237 | 238 | const time = timing.responseEnd - timing.responseStart; 239 | 240 | // If the transfer is ridiculously fast (> 200Mbps), then it most probably comes 241 | // from browser cache and timing is not reliable. 242 | if (time > 0 && timing.transferSize > 0 && timing.transferSize / time < 26214) { 243 | this.allTimings.push(timing); 244 | this.splitTimingIntoIntervals(timing); 245 | } 246 | } 247 | 248 | // To be able to estimate the bandwidth, we split this resource transferSize into 249 | // time intervals and add them to our timeline. 250 | splitTimingIntoIntervals(timing) { 251 | let startingBlock = Math.floor((timing.responseStart - this.epoch) / this.INTERVAL_DURATION); 252 | let endingBlock = Math.floor((timing.responseEnd - this.epoch) / this.INTERVAL_DURATION); 253 | let bytesPerBlock = timing.transferSize / ((endingBlock - startingBlock + 1)); 254 | 255 | for (var i = startingBlock; i <= endingBlock; i++) { 256 | this.allIntervals[i] = (this.allIntervals[i] || 0) + bytesPerBlock; 257 | } 258 | } 259 | 260 | // What a good idea we had, to save the Content-Length headers! 261 | // Because we need one. 262 | findContentLength(url) { 263 | for (var i = this.allContentLengths.length - 1; i >= 0; i--) { 264 | if (this.allContentLengths[i].url === url) { 265 | return parseInt(this.allContentLengths[i].size, 10); 266 | } 267 | } 268 | } 269 | 270 | // Saves the content-length data from a fetched response header 271 | addContentLength(url, response) { 272 | if (response.type !== 'opaque' && response.headers.has('content-length')) { 273 | this.allContentLengths.push({ 274 | url: url, 275 | size: response.headers.get('content-length') 276 | }); 277 | } 278 | } 279 | 280 | // Reads timings and estimates bandwidth 281 | estimateBandwidth() { 282 | 283 | // Let's estimate the bandwidth for some different periods of times (in seconds) 284 | const ages = [20, 60, 300, 86400]; // 20s, 1m, 5m, 1d 285 | const bandwidths = ages.map((bw) => this.estimateBandwidthForAPeriod(bw)); 286 | 287 | let result = this.averageWithWeight(bandwidths); 288 | 289 | // Always cap bandwidth with the theorical max speed of underlying network 290 | // (when the Network Information API is available, of course) 291 | // It makes the library more reactive when, for exemple, the user 292 | // sudenly looses its 3G signal and gets 2G instead. 293 | result = Math.min(result, this.getDownlinkMax()); 294 | 295 | return Math.round(result); 296 | } 297 | 298 | // Estimates bandwidth for the last given number of seconds 299 | estimateBandwidthForAPeriod(numberOfSeconds) { 300 | 301 | // Now, minus the number of minutes 302 | const from = Date.now() - this.epoch - (numberOfSeconds * 1000); 303 | 304 | // Retrieves corresponding cells in the timeline array 305 | const newArray = this.allIntervals.slice(from / this.INTERVAL_DURATION); 306 | 307 | if (newArray.length === 0) { 308 | return undefined; 309 | } 310 | 311 | // Sums up the transfered size in this duration 312 | const transferedSize = newArray.reduce((a, b) => a + b); 313 | 314 | // Skip estimating bandwidth if too few kilobytes were collected 315 | if (transferedSize < 51200) { 316 | return undefined; 317 | } 318 | 319 | // Now let's use the 90th percentile of all values 320 | // From my different tests, that percentile provides good results 321 | const nineteenthPercentile = this.percentile(newArray, .9); 322 | 323 | // Convert bytes per (this.INTERVAL_DURATION)ms to kilobytes per second (kilobytes, not kilobits!) 324 | const mbps = nineteenthPercentile * 1000 / this.INTERVAL_DURATION / 1024; 325 | 326 | return mbps; 327 | } 328 | 329 | // Reads timings and estimates Round Trip Time 330 | estimateRTT() { 331 | // Same as for bandwidth, we start by estimating the RTT on several periods of time 332 | const ages = [20, 60, 300, 86400]; // 20s, 1m, 5m, 1d 333 | const rtts = ages.map((bw) => this.estimateRTTForAPeriod(bw)); 334 | 335 | return Math.round(this.averageWithWeight(rtts)); 336 | } 337 | 338 | // Estimates RTT for the last given number of seconds 339 | estimateRTTForAPeriod(numberOfSeconds) { 340 | 341 | // Now, minus the number of minutes 342 | const from = Date.now() - (numberOfSeconds * 1000); 343 | 344 | let pings = this.allTimings.filter(timing => { 345 | return timing.responseEnd >= from; 346 | }).map(timing => { 347 | // The estimated RTT for one request is an average of: 348 | // DNS lookup time + First connection + SSL handshake + Time to First Byte 349 | // in milliseconds. 350 | // 351 | // Note: we can't rely on timing.secureConnectionStart because IE doesn't provide it. 352 | // But we're always on HTTPS, so let's just count TCP connection as two roundtrips. 353 | // 354 | const dns = timing.domainLookupEnd - timing.domainLookupStart; 355 | const tcp = timing.connectEnd - timing.connectStart; 356 | const ttfb = timing.responseStart - timing.requestStart; 357 | 358 | // Let's consider that any timing under 10ms is not valuable 359 | const roundtripsCount = (dns > 10) + ((tcp > 10) * 2) + (ttfb > 10); 360 | return roundtripsCount ? Math.round((dns + tcp + ttfb) / roundtripsCount) : null; 361 | }); 362 | 363 | // Skip estimating RTT if too few requests were analyzed 364 | if (pings.length < 3) { 365 | return undefined; 366 | } 367 | 368 | // Let's use the 20th percentile here, to eliminate servers' slowness 369 | return this.percentile(pings, .2); 370 | } 371 | 372 | // Returns the value at a given percentile in a numeric array. 373 | // Not very accurate, but accurate enough for our needs. 374 | percentile(arr, p) { 375 | 376 | // Remove undefineds and transfer to a new array 377 | let newArray = arr.filter((cell) => cell !== undefined); 378 | 379 | // Fail if there are no results 380 | if (newArray.length === 0) { 381 | return undefined; 382 | } 383 | 384 | newArray.sort((a, b) => a - b); 385 | 386 | return newArray[Math.floor(newArray.length * p)]; 387 | } 388 | 389 | // Returns the average of the array, but gives much more weight to the first values 390 | averageWithWeight(arr) { 391 | let total = 0; 392 | let totalWeights = 0; 393 | 394 | for (var i = 0; i < arr.length; i++) { 395 | if (arr[i] !== undefined) { 396 | 397 | let weight = 1 / Math.pow(i + 1, 3); 398 | // With that formula: 399 | // - the weight of the 1st value is 1 400 | // - of the 2nd value is 1/8 401 | // - of the 3rd value is 1/27 402 | // - of the 4th value is 1/64 403 | // ... 404 | 405 | total += arr[i] * weight; 406 | totalWeights += weight; 407 | } 408 | } 409 | 410 | if (totalWeights === 0) { 411 | return undefined; 412 | } 413 | 414 | return total / totalWeights; 415 | } 416 | 417 | simplifyTimingObject(timing) { 418 | return { 419 | name: timing.name, 420 | transferSize: timing.transferSize, 421 | domainLookupStart: Math.round(this.epoch + timing.domainLookupStart), 422 | domainLookupEnd: Math.round(this.epoch + timing.domainLookupEnd), 423 | connectStart: Math.round(this.epoch + timing.connectStart), 424 | connectEnd: Math.round(this.epoch + timing.connectEnd), 425 | requestStart: Math.round(this.epoch + timing.requestStart), 426 | responseStart: Math.round(this.epoch + timing.responseStart), 427 | responseEnd: Math.round(this.epoch + timing.responseEnd) 428 | }; 429 | } 430 | 431 | getDownlinkMax() { 432 | if (self.navigator.connection && self.navigator.connection.downlinkMax > 0) { 433 | return self.navigator.connection.downlinkMax * 256; // convert Mbps to KBps 434 | } 435 | return Infinity; 436 | } 437 | 438 | initDatabase() { 439 | // Open database connection 440 | let dbPromise = self.indexedDB.open('howslow', 1) 441 | 442 | dbPromise.onupgradeneeded = (event) => { 443 | if (!event.target.result.objectStoreNames.contains('bw')) { 444 | event.target.result.createObjectStore('bw'); 445 | } 446 | }; 447 | 448 | dbPromise.onsuccess = (event) => { 449 | this.database = event.target.result; 450 | this.retrieveStats(); 451 | }; 452 | 453 | // Not handling DB errors cause it's ok, we can still work without DB 454 | } 455 | 456 | // Saves bandwidth & RTT to IndexedDB 457 | saveStats() { 458 | let object = { 459 | bandwidth: this.bandwidth, 460 | rtt: this.rtt, 461 | connectionType: self.navigator.connection && self.navigator.connection.type 462 | }; 463 | 464 | try { 465 | this.database.transaction('bw', 'readwrite').objectStore('bw').put(object, 1); 466 | } catch(error) { 467 | // Silent error 468 | } 469 | } 470 | 471 | // Reads the latest known bandwidth & RTT from IndexedDB 472 | retrieveStats() { 473 | try { 474 | this.database.transaction('bw', 'readonly').objectStore('bw').get(1).onsuccess = (event) => { 475 | if (event.target.result) { 476 | this.bandwidthFromDatabase = event.target.result.bandwidth || undefined; 477 | this.rttFromDatabase = event.target.result.rtt || undefined; 478 | this.connectionTypeFromDatabase = event.target.result.connectionType; 479 | } 480 | }; 481 | } catch(error) { 482 | // Silent error 483 | } 484 | } 485 | } 486 | 487 | // Let's go! 488 | self.howslow = new HowSlowForSW(); -------------------------------------------------------------------------------- /docs/demo/images/image-L.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image-L.jpg -------------------------------------------------------------------------------- /docs/demo/images/image-M.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image-M.jpg -------------------------------------------------------------------------------- /docs/demo/images/image-S.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image-S.jpg -------------------------------------------------------------------------------- /docs/demo/images/image-XL.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image-XL.jpg -------------------------------------------------------------------------------- /docs/demo/images/image-XS.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image-XS.jpg -------------------------------------------------------------------------------- /docs/demo/images/image-unknown.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image-unknown.jpg -------------------------------------------------------------------------------- /docs/demo/images/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/demo/images/image.jpg -------------------------------------------------------------------------------- /docs/demo/myUrlRewritingFunction.js: -------------------------------------------------------------------------------- 1 | function urlRewritingHook(url) { 2 | 3 | // This is some demo code. Adapt to your needs. 4 | 5 | // Let's intercept every call to image.jpg 6 | const regexp = /images\/image\.jpg\?timestamp=(.*)$/; 7 | const execResult = regexp.exec(url); 8 | 9 | if (execResult !== null) { 10 | 11 | // ... and choose the right image! 12 | var bandwidth = howslow.getBandwidth(); 13 | 14 | if (bandwidth > 4000) { 15 | return 'images/image-XL.jpg?timestamp=' + execResult[1]; 16 | } else if (bandwidth > 1000) { 17 | return 'images/image-L.jpg?timestamp=' + execResult[1]; 18 | } else if (bandwidth > 200) { 19 | return 'images/image-M.jpg?timestamp=' + execResult[1]; 20 | } else if (bandwidth > 50) { 21 | return 'images/image-S.jpg?timestamp=' + execResult[1]; 22 | } else if (bandwidth > 10) { 23 | return 'images/image-XS.jpg?timestamp=' + execResult[1]; 24 | } else { 25 | return 'images/image-unknown.jpg?timestamp=' + execResult[1]; 26 | } 27 | } 28 | 29 | // Return null for urls you don't want to change 30 | return null; 31 | } -------------------------------------------------------------------------------- /docs/howslow-waterfall-to-stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fasterize/HowSlow/7efdb6d837fc440d6101ab45250d93e5824f8aed/docs/howslow-waterfall-to-stats.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | [Demo page](demo/demo.html) -------------------------------------------------------------------------------- /examples/adjust-image-density/page.html: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 24 | 25 | 49 | 50 | -------------------------------------------------------------------------------- /examples/block-fonts/sw.js: -------------------------------------------------------------------------------- 1 | // Load howslow 2 | self.importScripts('howSlowForSW.js'); 3 | 4 | // Don't just block the WOFF2 file, block all extentions because when 5 | // a font is in error, the browser will try the other font formats. 6 | function urlBlockingHook(url) { 7 | const execResult = /\/static\/fonts\/fontname\.(woff|woff2|ttf)/.exec(url); 8 | return (execResult !== null && howslow.getBandwidth() < 50); 9 | } -------------------------------------------------------------------------------- /examples/block-third-parties/sw.js: -------------------------------------------------------------------------------- 1 | // Load howslow 2 | self.importScripts('howSlowForSW.js'); 3 | 4 | // Returning true will block the resource with the HTTP code "446 Blocked by Service Worker" 5 | function urlBlockingHook(url) { 6 | return (url === 'https://thirdparty.com/tracker.js' && howslow.getBandwidth() < 50); 7 | } -------------------------------------------------------------------------------- /examples/change-images-urls/sw.js: -------------------------------------------------------------------------------- 1 | // Load howslow 2 | self.importScripts('howSlowForSW.js'); 3 | 4 | // Here we use the urlRewritingHook. This function is called on each resource 5 | // fetched by the page. 6 | // If the return value is null, nothing is changed. 7 | // If the value is an URL, it replaces the inital URL. 8 | function urlRewritingHook(url) { 9 | 10 | // Let's intercept every editorial image call 11 | const regexp = /images\/editorial\/(.*)\.jpg$/; 12 | const execResult = regexp.exec(url); 13 | 14 | if (execResult !== null && howslow.getBandwidth() > 1000) { 15 | // Add an "-hd" suffix to the image name 16 | return '/images/editorial/' + execResult[1] + '-hd.jpg'; 17 | } 18 | 19 | // Respond null for the default behavior 20 | return null; 21 | } -------------------------------------------------------------------------------- /examples/show-connectivity-message/page.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 36 | -------------------------------------------------------------------------------- /howSlowForPage.js: -------------------------------------------------------------------------------- 1 | class HowSlowForPage { 2 | 3 | constructor(swPath) { 4 | this.bandwidth = undefined; 5 | this.rtt = undefined; 6 | this.firstRequestSent = false; 7 | this.navigationStart = 0; 8 | 9 | this.initSW(swPath).then(() => this.listenToSW()); 10 | 11 | this.storageIO(); 12 | } 13 | 14 | getBandwidth() { 15 | return this.bandwidth; 16 | } 17 | 18 | getRTT() { 19 | return this.rtt; 20 | } 21 | 22 | // Initializes the Service Worker, or at least tries 23 | initSW(swPath) { 24 | return new Promise((resolve, reject) => { 25 | if (!('serviceWorker' in window.navigator)) { 26 | // No browser support 27 | reject('Service Workers not supported'); 28 | } 29 | 30 | if (window.navigator.serviceWorker.controller) { 31 | if (window.navigator.serviceWorker.controller.scriptURL.indexOf(swPath) >= 0) { 32 | // The service worker is already active 33 | resolve(); 34 | } else { 35 | reject('Giving up. Service Worker conflict with: ' + window.navigator.serviceWorker.controller.scriptURL); 36 | } 37 | } else { 38 | window.navigator.serviceWorker.register(swPath) 39 | .then(window.navigator.serviceWorker.ready) 40 | .then(function(serviceWorkerRegistration) { 41 | // The service worker is registered and should even be ready now 42 | resolve(); 43 | }); 44 | } 45 | }); 46 | } 47 | 48 | // Geting ready to receive messages from the Service Worker 49 | listenToSW() { 50 | window.navigator.serviceWorker.onmessage = (event) => { 51 | if (event.data.command === 'timingsPlz') { 52 | // The Service Workers asks for resource timings 53 | const timings = this.readLatestResourceTimings(); 54 | 55 | if (timings.length > 0) { 56 | this.sendResourceTimings(timings); 57 | } 58 | } else if (event.data.command === 'stats') { 59 | // The Service Workers sends the latest stats 60 | this.bandwidth = event.data.bandwidth; 61 | this.rtt = event.data.rtt; 62 | } 63 | }; 64 | } 65 | 66 | // Gathers the latest ResourceTimings and sends them to SW 67 | sendResourceTimings(simplifiedTimings) { 68 | try { 69 | navigator.serviceWorker.controller.postMessage({ 70 | 'command': 'eatThat', 71 | 'timings': simplifiedTimings 72 | }); 73 | } catch(error) {} 74 | } 75 | 76 | // Gathers the ResourceTimings from the API 77 | readLatestResourceTimings() { 78 | 79 | // Not compatible browsers 80 | if (!window.performance || !window.performance.getEntriesByType || !window.performance.timing) { 81 | return []; 82 | } 83 | 84 | let timings = []; 85 | 86 | if (!this.firstRequestSent) { 87 | 88 | // Save this for later 89 | this.navigationStart = window.performance.timing.navigationStart; 90 | 91 | // The first HTML resource is as intersting as the others... maybe even more! 92 | timings.push({ 93 | name: window.location.href, 94 | transferSize: window.performance.timing.transferSize, 95 | domainLookupStart: window.performance.timing.domainLookupStart, 96 | domainLookupEnd: window.performance.timing.domainLookupEnd, 97 | connectStart: window.performance.timing.connectStart, 98 | connectEnd: window.performance.timing.connectEnd, 99 | requestStart: window.performance.timing.requestStart, 100 | responseStart: window.performance.timing.responseStart, 101 | responseEnd: window.performance.timing.responseEnd 102 | }); 103 | 104 | this.firstRequestSent = true; 105 | } 106 | 107 | window.performance.getEntriesByType('resource').forEach((timing) => { 108 | timings.push({ 109 | name: timing.name, 110 | transferSize: timing.transferSize, 111 | domainLookupStart: Math.round(this.navigationStart + timing.domainLookupStart), 112 | domainLookupEnd: Math.round(this.navigationStart + timing.domainLookupEnd), 113 | connectStart: Math.round(this.navigationStart + timing.connectStart), 114 | connectEnd: Math.round(this.navigationStart + timing.connectEnd), 115 | secureConnectionStart: Math.round(this.navigationStart + timing.secureConnectionStart), 116 | requestStart: Math.round(this.navigationStart + timing.requestStart), 117 | responseStart: Math.round(this.navigationStart + timing.responseStart), 118 | responseEnd: Math.round(this.navigationStart + timing.responseEnd) 119 | }); 120 | }); 121 | 122 | // Now lets clear resourceTimings 123 | window.performance.clearResourceTimings(); 124 | window.performance.setResourceTimingBufferSize(200); 125 | 126 | // TODO: add an option to avoid clearing ResourceTimings... 127 | // ... some other scripts might need them! 128 | 129 | return timings; 130 | } 131 | 132 | // On the SW's side, stats are saved in IndexedDB. Here we have access to LocalStorage. 133 | storageIO() { 134 | // When leaving the page, save stats into LocalStorage for faster ignition 135 | window.addEventListener('unload', () => { 136 | if (this.bandwidth || this.rtt) { 137 | window.localStorage.setItem('howslow', this.bandwidth + ',' + this.rtt); 138 | } 139 | }); 140 | 141 | // And when arriving on the page, retrieve stats 142 | let stats = window.localStorage.getItem('howslow'); 143 | if (stats) { 144 | stats = stats.split(','); 145 | this.bandwidth = stats[0] || undefined; 146 | this.rtt = stats[1] || undefined; 147 | } 148 | } 149 | } -------------------------------------------------------------------------------- /howSlowForSW.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* ----- ↑ Write your own service workers rules above this line ↑ ----- */ 4 | /* ----- ↓ Change below this line at your own risks ↓ -------------------------------- */ 5 | 6 | // Service Worker initialization 7 | self.addEventListener('install', () => { 8 | self.skipWaiting(); // Activate worker immediately 9 | }); 10 | 11 | self.addEventListener('activate', () => { 12 | if (self.clients && self.clients.claim) { 13 | // Make it available immediately 14 | self.clients.claim().then(() => { 15 | // The service worker is ready to work 16 | 17 | // The attached pages might already have some resource timings available. 18 | // Let's ask! 19 | howslow.askTimingsToClients(); 20 | }); 21 | } 22 | }); 23 | 24 | // Intercept requests 25 | self.addEventListener('fetch', (event) => { 26 | 27 | if (typeof urlBlockingHook === 'function' && urlBlockingHook(event.request.url) === true) { 28 | event.respondWith(new Response('', { 29 | status: 446, 30 | statusText: 'Blocked by Service Worker' 31 | })); 32 | return; 33 | } 34 | 35 | let modifiedUrl = (typeof urlRewritingHook === 'function') ? urlRewritingHook(event.request.url) : null; 36 | let options = {}; 37 | 38 | if (modifiedUrl) { 39 | // Add credentials to the request otherwise the fetch method opens a new connection 40 | options.credentials = 'include'; 41 | } 42 | 43 | event.respondWith( 44 | fetch((modifiedUrl || event.request), options) 45 | .then(function(response) { 46 | // Save the content-length header 47 | howslow.addContentLength(response.url, response); 48 | return response; 49 | }) 50 | ); 51 | }); 52 | 53 | 54 | class HowSlowForSW { 55 | 56 | constructor() { 57 | this.allTimings = []; 58 | this.allContentLengths = []; 59 | this.allIntervals = []; 60 | this.INTERVAL_DURATION = 25; // in milliseconds 61 | 62 | // That's our supposed service worker initialization time 63 | // TODO: replace by the reference time used inside the SW's Resource Timings 64 | // (I have no idea how to find it) 65 | this.epoch = Date.now(); 66 | 67 | // Start the ticker 68 | this.tick(); 69 | 70 | // Listen to the broadcast responses 71 | self.addEventListener('message', (event) => { 72 | if (event.data.command === 'eatThat') { 73 | // Some new timings just arrived from a page 74 | event.data.timings.forEach((timing) => { 75 | this.addOneTiming(timing) 76 | }); 77 | } 78 | }); 79 | 80 | this.initDatabase(); 81 | } 82 | 83 | // Every second: 84 | tick() { 85 | // Do that... 86 | this.refreshStats(); 87 | this.sendStatsToClients(); 88 | 89 | // ... and repeat 90 | setTimeout(() => { 91 | this.tick(); 92 | }, 1000); 93 | } 94 | 95 | getBandwidth() { 96 | if (this.bandwidth) { 97 | return this.bandwidth; 98 | } 99 | 100 | // If we couldn't estimate bandwidth yet, but we've got a record in database 101 | // We serve the saved bandwidth 102 | if (!this.connectionTypeFromDatabase 103 | || !self.navigator.connection 104 | || !self.navigator.connection.type 105 | || this.connectionTypeFromDatabase === self.navigator.connection.type) { 106 | 107 | return this.bandwidthFromDatabase; 108 | } 109 | 110 | return undefined; 111 | } 112 | 113 | getRTT() { 114 | if (this.rtt) { 115 | return this.rtt; 116 | } 117 | 118 | // If we couldn't estimate bandwidth yet, but we've got a record in database 119 | // We serve the saved bandwidth 120 | if (!this.connectionTypeFromDatabase 121 | || !self.navigator.connection 122 | || !self.navigator.connection.type 123 | || this.connectionTypeFromDatabase === self.navigator.connection.type) { 124 | 125 | return this.rttFromDatabase; 126 | } 127 | 128 | return undefined; 129 | } 130 | 131 | // Updates bandwidth & rtt 132 | refreshStats() { 133 | 134 | // Update the data from resource timings 135 | this.refreshTimings(); 136 | 137 | // Use the data to estimate bandwidth 138 | this.bandwidth = this.estimateBandwidth(); 139 | this.rtt = this.estimateRTT(); 140 | 141 | // If the bandwith or the RTT were correctly estimated, 142 | // we save them to database 143 | if (this.bandwidth || this.rtt) { 144 | this.saveStats(); 145 | } 146 | } 147 | 148 | // Collects the latest resource timings 149 | refreshTimings() { 150 | 151 | if (self.performance && self.performance.getEntriesByType) { 152 | // If the Service Worker has access to the Resource Timing API, 153 | // It's easy, we just read it. 154 | 155 | self.performance.getEntriesByType('resource').forEach((timing) => { 156 | this.addOneTiming(this.simplifyTimingObject(timing)); 157 | }); 158 | 159 | // Then we empty the history 160 | self.performance.clearResourceTimings(); 161 | 162 | // "The clearResourceTimings() method removes all performance entries [...] and sets 163 | // the size of the performance data buffer to zero. To set the size of the browser's 164 | // performance data buffer, use the Performance.setResourceTimingBufferSize() method." 165 | self.performance.setResourceTimingBufferSize(200); 166 | } 167 | 168 | // TODO : the "else" part, when the Service Workers doesn't have access to 169 | // the Resource Timing API (Microsoft Edge) 170 | } 171 | 172 | // Sends a request to all clients for their resource timings. 173 | askTimingsToClients() { 174 | this.sendMessageToAllClients({ 175 | command: 'timingsPlz' 176 | }); 177 | } 178 | 179 | // Send bandwidth and RTT to all clients 180 | sendStatsToClients() { 181 | this.sendMessageToAllClients({ 182 | command: 'stats', 183 | bandwidth: this.getBandwidth(), 184 | rtt: this.getRTT() 185 | }); 186 | } 187 | 188 | // Sends a message to all clients 189 | sendMessageToAllClients(json) { 190 | self.clients.matchAll() 191 | .then((clients) => { 192 | clients.forEach((client) => { 193 | client.postMessage(json); 194 | }); 195 | }); 196 | } 197 | 198 | // Saves one timing in the allTimings list 199 | // only if it doesn't look like it comes from the browser's cache 200 | addOneTiming(timing) { 201 | 202 | // If we don't have the transfer size (Safari & Edge don't provide it) 203 | // than let's try to read it from the Content-Length headers. 204 | if (!timing.transferSize) { 205 | timing.transferSize = this.findContentLength(timing.name); 206 | } 207 | 208 | const time = timing.responseEnd - timing.responseStart; 209 | 210 | // If the transfer is ridiculously fast (> 200Mbps), then it most probably comes 211 | // from browser cache and timing is not reliable. 212 | if (time > 0 && timing.transferSize > 0 && timing.transferSize / time < 26214) { 213 | this.allTimings.push(timing); 214 | this.splitTimingIntoIntervals(timing); 215 | } 216 | } 217 | 218 | // To be able to estimate the bandwidth, we split this resource transferSize into 219 | // time intervals and add them to our timeline. 220 | splitTimingIntoIntervals(timing) { 221 | let startingBlock = Math.floor((timing.responseStart - this.epoch) / this.INTERVAL_DURATION); 222 | let endingBlock = Math.floor((timing.responseEnd - this.epoch) / this.INTERVAL_DURATION); 223 | let bytesPerBlock = timing.transferSize / ((endingBlock - startingBlock + 1)); 224 | 225 | for (var i = startingBlock; i <= endingBlock; i++) { 226 | this.allIntervals[i] = (this.allIntervals[i] || 0) + bytesPerBlock; 227 | } 228 | } 229 | 230 | // What a good idea we had, to save the Content-Length headers! 231 | // Because we need one. 232 | findContentLength(url) { 233 | for (var i = this.allContentLengths.length - 1; i >= 0; i--) { 234 | if (this.allContentLengths[i].url === url) { 235 | return parseInt(this.allContentLengths[i].size, 10); 236 | } 237 | } 238 | } 239 | 240 | // Saves the content-length data from a fetched response header 241 | addContentLength(url, response) { 242 | if (response.type !== 'opaque' && response.headers.has('content-length')) { 243 | this.allContentLengths.push({ 244 | url: url, 245 | size: response.headers.get('content-length') 246 | }); 247 | } 248 | } 249 | 250 | // Reads timings and estimates bandwidth 251 | estimateBandwidth() { 252 | 253 | // Let's estimate the bandwidth for some different periods of times (in seconds) 254 | const ages = [20, 60, 300, 86400]; // 20s, 1m, 5m, 1d 255 | const bandwidths = ages.map((bw) => this.estimateBandwidthForAPeriod(bw)); 256 | 257 | let result = this.averageWithWeight(bandwidths); 258 | 259 | // Always cap bandwidth with the theorical max speed of underlying network 260 | // (when the Network Information API is available, of course) 261 | // It makes the library more reactive when, for exemple, the user 262 | // sudenly looses its 3G signal and gets 2G instead. 263 | result = Math.min(result, this.getDownlinkMax()); 264 | 265 | return Math.round(result); 266 | } 267 | 268 | // Estimates bandwidth for the last given number of seconds 269 | estimateBandwidthForAPeriod(numberOfSeconds) { 270 | 271 | // Now, minus the number of minutes 272 | const from = Date.now() - this.epoch - (numberOfSeconds * 1000); 273 | 274 | // Retrieves corresponding cells in the timeline array 275 | const newArray = this.allIntervals.slice(from / this.INTERVAL_DURATION); 276 | 277 | if (newArray.length === 0) { 278 | return undefined; 279 | } 280 | 281 | // Sums up the transfered size in this duration 282 | const transferedSize = newArray.reduce((a, b) => a + b); 283 | 284 | // Skip estimating bandwidth if too few kilobytes were collected 285 | if (transferedSize < 51200) { 286 | return undefined; 287 | } 288 | 289 | // Now let's use the 90th percentile of all values 290 | // From my different tests, that percentile provides good results 291 | const nineteenthPercentile = this.percentile(newArray, .9); 292 | 293 | // Convert bytes per (this.INTERVAL_DURATION)ms to kilobytes per second (kilobytes, not kilobits!) 294 | const mbps = nineteenthPercentile * 1000 / this.INTERVAL_DURATION / 1024; 295 | 296 | return mbps; 297 | } 298 | 299 | // Reads timings and estimates Round Trip Time 300 | estimateRTT() { 301 | // Same as for bandwidth, we start by estimating the RTT on several periods of time 302 | const ages = [20, 60, 300, 86400]; // 20s, 1m, 5m, 1d 303 | const rtts = ages.map((bw) => this.estimateRTTForAPeriod(bw)); 304 | 305 | return Math.round(this.averageWithWeight(rtts)); 306 | } 307 | 308 | // Estimates RTT for the last given number of seconds 309 | estimateRTTForAPeriod(numberOfSeconds) { 310 | 311 | // Now, minus the number of minutes 312 | const from = Date.now() - (numberOfSeconds * 1000); 313 | 314 | let pings = this.allTimings.filter(timing => { 315 | return timing.responseEnd >= from; 316 | }).map(timing => { 317 | // The estimated RTT for one request is an average of: 318 | // DNS lookup time + First connection + SSL handshake + Time to First Byte 319 | // in milliseconds. 320 | // 321 | // Note: we can't rely on timing.secureConnectionStart because IE doesn't provide it. 322 | // But we're always on HTTPS, so let's just count TCP connection as two roundtrips. 323 | // 324 | const dns = timing.domainLookupEnd - timing.domainLookupStart; 325 | const tcp = timing.connectEnd - timing.connectStart; 326 | const ttfb = timing.responseStart - timing.requestStart; 327 | 328 | // Let's consider that any timing under 10ms is not valuable 329 | const roundtripsCount = (dns > 10) + ((tcp > 10) * 2) + (ttfb > 10); 330 | return roundtripsCount ? Math.round((dns + tcp + ttfb) / roundtripsCount) : null; 331 | }); 332 | 333 | // Skip estimating RTT if too few requests were analyzed 334 | if (pings.length < 3) { 335 | return undefined; 336 | } 337 | 338 | // Let's use the 20th percentile here, to eliminate servers' slowness 339 | return this.percentile(pings, .2); 340 | } 341 | 342 | // Returns the value at a given percentile in a numeric array. 343 | // Not very accurate, but accurate enough for our needs. 344 | percentile(arr, p) { 345 | 346 | // Remove undefineds and transfer to a new array 347 | let newArray = arr.filter((cell) => cell !== undefined); 348 | 349 | // Fail if there are no results 350 | if (newArray.length === 0) { 351 | return undefined; 352 | } 353 | 354 | newArray.sort((a, b) => a - b); 355 | 356 | return newArray[Math.floor(newArray.length * p)]; 357 | } 358 | 359 | // Returns the average of the array, but gives much more weight to the first values 360 | averageWithWeight(arr) { 361 | let total = 0; 362 | let totalWeights = 0; 363 | 364 | for (var i = 0; i < arr.length; i++) { 365 | if (arr[i] !== undefined) { 366 | 367 | let weight = 1 / Math.pow(i + 1, 3); 368 | // With that formula: 369 | // - the weight of the 1st value is 1 370 | // - of the 2nd value is 1/8 371 | // - of the 3rd value is 1/27 372 | // - of the 4th value is 1/64 373 | // ... 374 | 375 | total += arr[i] * weight; 376 | totalWeights += weight; 377 | } 378 | } 379 | 380 | if (totalWeights === 0) { 381 | return undefined; 382 | } 383 | 384 | return total / totalWeights; 385 | } 386 | 387 | simplifyTimingObject(timing) { 388 | return { 389 | name: timing.name, 390 | transferSize: timing.transferSize, 391 | domainLookupStart: Math.round(this.epoch + timing.domainLookupStart), 392 | domainLookupEnd: Math.round(this.epoch + timing.domainLookupEnd), 393 | connectStart: Math.round(this.epoch + timing.connectStart), 394 | connectEnd: Math.round(this.epoch + timing.connectEnd), 395 | requestStart: Math.round(this.epoch + timing.requestStart), 396 | responseStart: Math.round(this.epoch + timing.responseStart), 397 | responseEnd: Math.round(this.epoch + timing.responseEnd) 398 | }; 399 | } 400 | 401 | getDownlinkMax() { 402 | if (self.navigator.connection && self.navigator.connection.downlinkMax > 0) { 403 | return self.navigator.connection.downlinkMax * 256; // convert Mbps to KBps 404 | } 405 | return Infinity; 406 | } 407 | 408 | initDatabase() { 409 | // Open database connection 410 | let dbPromise = self.indexedDB.open('howslow', 1) 411 | 412 | dbPromise.onupgradeneeded = (event) => { 413 | if (!event.target.result.objectStoreNames.contains('bw')) { 414 | event.target.result.createObjectStore('bw'); 415 | } 416 | }; 417 | 418 | dbPromise.onsuccess = (event) => { 419 | this.database = event.target.result; 420 | this.retrieveStats(); 421 | }; 422 | 423 | // Not handling DB errors cause it's ok, we can still work without DB 424 | } 425 | 426 | // Saves bandwidth & RTT to IndexedDB 427 | saveStats() { 428 | let object = { 429 | bandwidth: this.bandwidth, 430 | rtt: this.rtt, 431 | connectionType: self.navigator.connection && self.navigator.connection.type 432 | }; 433 | 434 | try { 435 | this.database.transaction('bw', 'readwrite').objectStore('bw').put(object, 1); 436 | } catch(error) { 437 | // Silent error 438 | } 439 | } 440 | 441 | // Reads the latest known bandwidth & RTT from IndexedDB 442 | retrieveStats() { 443 | try { 444 | this.database.transaction('bw', 'readonly').objectStore('bw').get(1).onsuccess = (event) => { 445 | if (event.target.result) { 446 | this.bandwidthFromDatabase = event.target.result.bandwidth || undefined; 447 | this.rttFromDatabase = event.target.result.rtt || undefined; 448 | this.connectionTypeFromDatabase = event.target.result.connectionType; 449 | } 450 | }; 451 | } catch(error) { 452 | // Silent error 453 | } 454 | } 455 | } 456 | 457 | // Let's go! 458 | self.howslow = new HowSlowForSW(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "HowSlow", 3 | "version": "0.0.1", 4 | "description": "Bandwidth and RTT estimation script", 5 | "main": "index.js", 6 | "scripts": { 7 | "build-demo": "cat docs/demo/myUrlRewritingFunction.js howSlowForSW.js > docs/demo/forSW.js; babel howSlowForPage.js --out-file docs/demo/forPage.js;" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/fasterize/HowSlow.git" 12 | }, 13 | "keywords": [ 14 | "webperf", 15 | "performance", 16 | "speed", 17 | "slowness", 18 | "detection", 19 | "bandwidth" 20 | ], 21 | "author": "Fasterize", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/fasterize/HowSlow/issues" 25 | }, 26 | "homepage": "https://github.com/fasterize/HowSlow#readme", 27 | "devDependencies": { 28 | "babel-cli": "6.26.0", 29 | "babel-preset-es2015": "6.24.1" 30 | } 31 | } 32 | --------------------------------------------------------------------------------