diff --git a/package.json b/package.json
index 11e7a31d9a..ae470ef71b 100644
--- a/package.json
+++ b/package.json
@@ -58,7 +58,6 @@
   },
   "dependencies": {
     "browser-request": "^0.3.3",
-    "favico.js": "^0.3.10",
     "gfm.css": "^1.1.2",
     "highlight.js": "^9.13.1",
     "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index 9716cafe22..4cb74963d9 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -24,5 +24,11 @@ declare global {
 
         // electron-only
         ipcRenderer: any;
+
+        // opera-only
+        opera: any;
+
+        // https://developer.mozilla.org/en-US/docs/Web/API/InstallTrigger
+        InstallTrigger: any;
     }
 }
diff --git a/src/favicon.ts b/src/favicon.ts
new file mode 100644
index 0000000000..06d6268106
--- /dev/null
+++ b/src/favicon.ts
@@ -0,0 +1,256 @@
+/*
+Copyright 2020 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Based upon https://github.com/ejci/favico.js/blob/master/favico.js [MIT license]
+
+interface IParams {
+    // colour parameters
+    bgColor: string;
+    textColor: string;
+    // font styling parameters
+    fontFamily: string;
+    fontWeight: "normal" | "italic" | "bold" | "bolder" | "lighter" | number;
+
+    // positioning parameters
+    isUp: boolean;
+    isLeft: boolean;
+}
+
+const defaults: IParams = {
+    bgColor: "#d00",
+    textColor: "#fff",
+    fontFamily: "sans-serif", // Arial,Verdana,Times New Roman,serif,sans-serif,...
+    fontWeight: "bold", // normal,italic,oblique,bold,bolder,lighter,100,200,300,400,500,600,700,800,900
+
+    isUp: false,
+    isLeft: false,
+};
+
+// Allows dynamic rendering of a circular badge atop the loaded favicon
+// supports colour, font and basic positioning parameters.
+export default class Favicon {
+    private readonly browser = {
+        ff: typeof window.InstallTrigger !== "undefined",
+        opera: !!window.opera || navigator.userAgent.includes("Opera"),
+    };
+
+    private readonly params: IParams;
+    private readonly canvas: HTMLCanvasElement;
+    private readonly baseImage: HTMLImageElement;
+    private context: CanvasRenderingContext2D;
+    private icons: HTMLLinkElement[];
+
+    private isReady: boolean = false;
+    // callback to run once isReady is asserted, allows for a badge to be queued for when it can be shown
+    private readyCb = () => {};
+
+    constructor(params: Partial<IParams> = {}) {
+        this.params = {...defaults, ...params};
+
+        this.icons = Favicon.getIcons();
+        // create work canvas
+        this.canvas = document.createElement("canvas");
+        // create clone of favicon as a base
+        this.baseImage = document.createElement("img");
+
+        const lastIcon = this.icons[this.icons.length - 1];
+        if (lastIcon.hasAttribute("href")) {
+            this.baseImage.setAttribute("crossOrigin", "anonymous");
+            this.baseImage.onload = () => {
+                // get height and width of the favicon
+                this.canvas.height = (this.baseImage.height > 0) ? this.baseImage.height : 32;
+                this.canvas.width = (this.baseImage.width > 0) ? this.baseImage.width : 32;
+                this.context = this.canvas.getContext("2d");
+                this.ready();
+            };
+            this.baseImage.setAttribute("src", lastIcon.getAttribute("href"));
+        } else {
+            this.canvas.height = this.baseImage.height = 32;
+            this.canvas.width = this.baseImage.width = 32;
+            this.context = this.canvas.getContext("2d");
+            this.ready();
+        }
+    }
+
+    private reset() {
+        this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
+        this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
+    }
+
+    private options(n: number | string) {
+        const opt = {
+            n: ((typeof n) === "number") ? Math.abs(n as number | 0) : n,
+            len: ("" + n).length,
+            // badge positioning constants as percentages
+            x: 0.4,
+            y: 0.4,
+            w: 0.6,
+            h: 0.6,
+        };
+
+        // apply positional transformations
+        if (this.params.isUp) {
+            if (opt.y < 0.6) {
+                opt.y = opt.y - 0.4;
+            } else {
+                opt.y = opt.y - 2 * opt.y + (1 - opt.w);
+            }
+        }
+        if (this.params.isLeft) {
+            if (opt.x < 0.6) {
+                opt.x = opt.x - 0.4;
+            } else {
+                opt.x = opt.x - 2 * opt.x + (1 - opt.h);
+            }
+        }
+
+        // scale the position to the canvas
+        opt.x = this.canvas.width * opt.x;
+        opt.y = this.canvas.height * opt.y;
+        opt.w = this.canvas.width * opt.w;
+        opt.h = this.canvas.height * opt.h;
+        return opt;
+    }
+
+    private circle(n: number | string) {
+        const opt = this.options(n);
+
+        let more = false;
+        if (opt.len === 2) {
+            opt.x = opt.x - opt.w * 0.4;
+            opt.w = opt.w * 1.4;
+            more = true;
+        } else if (opt.len >= 3) {
+            opt.x = opt.x - opt.w * 0.65;
+            opt.w = opt.w * 1.65;
+            more = true;
+        }
+
+        this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
+        this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
+        this.context.beginPath();
+        const fontSize = Math.floor(opt.h * (opt.n > 99 ? 0.85 : 1)) + "px";
+        this.context.font = `${this.params.fontWeight} ${fontSize} ${this.params.fontFamily}`;
+        this.context.textAlign = "center";
+
+        if (more) {
+            this.context.moveTo(opt.x + opt.w / 2, opt.y);
+            this.context.lineTo(opt.x + opt.w - opt.h / 2, opt.y);
+            this.context.quadraticCurveTo(opt.x + opt.w, opt.y, opt.x + opt.w, opt.y + opt.h / 2);
+            this.context.lineTo(opt.x + opt.w, opt.y + opt.h - opt.h / 2);
+            this.context.quadraticCurveTo(opt.x + opt.w, opt.y + opt.h, opt.x + opt.w - opt.h / 2, opt.y + opt.h);
+            this.context.lineTo(opt.x + opt.h / 2, opt.y + opt.h);
+            this.context.quadraticCurveTo(opt.x, opt.y + opt.h, opt.x, opt.y + opt.h - opt.h / 2);
+            this.context.lineTo(opt.x, opt.y + opt.h / 2);
+            this.context.quadraticCurveTo(opt.x, opt.y, opt.x + opt.h / 2, opt.y);
+        } else {
+            this.context.arc(opt.x + opt.w / 2, opt.y + opt.h / 2, opt.h / 2, 0, 2 * Math.PI);
+        }
+
+        this.context.fillStyle = this.params.bgColor;
+        this.context.fill();
+        this.context.closePath();
+        this.context.beginPath();
+        this.context.stroke();
+        this.context.fillStyle = this.params.textColor;
+
+        if ((typeof opt.n) === "number" && opt.n > 999) {
+            const count = ((opt.n > 9999) ? 9 : Math.floor(opt.n as number / 1000)) + "k+";
+            this.context.fillText(count, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2));
+        } else {
+            this.context.fillText("" + opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15));
+        }
+
+        this.context.closePath();
+    }
+
+    private ready() {
+        if (this.isReady) return;
+        this.isReady = true;
+        this.readyCb();
+    }
+
+    private setIcon(canvas) {
+        setImmediate(() => {
+            this.setIconSrc(canvas.toDataURL("image/png"));
+        });
+    }
+
+    private setIconSrc(url) {
+        // if is attached to fav icon
+        if (this.browser.ff || this.browser.opera) {
+            // for FF we need to "recreate" element, attach to dom and remove old <link>
+            const old = this.icons[this.icons.length - 1];
+            const newIcon = window.document.createElement("link");
+            this.icons = [newIcon];
+            newIcon.setAttribute("rel", "icon");
+            newIcon.setAttribute("type", "image/png");
+            window.document.getElementsByTagName("head")[0].appendChild(newIcon);
+            newIcon.setAttribute("href", url);
+            if (old.parentNode) {
+                old.parentNode.removeChild(old);
+            }
+        } else {
+            this.icons.forEach(icon => {
+                icon.setAttribute("href", url);
+            });
+        }
+    }
+
+    public badge(content: number | string) {
+        if (!this.isReady) {
+            this.readyCb = () => {
+                this.badge(content);
+            }
+            return;
+        }
+
+        if (typeof content === "string" || content > 0) {
+            this.circle(content);
+        } else {
+            this.reset();
+        }
+
+        this.setIcon(this.canvas);
+    }
+
+    private static getLinks() {
+        const icons: HTMLLinkElement[] = [];
+        const links = window.document.getElementsByTagName("head")[0].getElementsByTagName("link");
+        for (let i = 0; i < links.length; i++) {
+            if ((/(^|\s)icon(\s|$)/i).test(links[i].getAttribute("rel"))) {
+                icons.push(links[i]);
+            }
+        }
+        return icons;
+    }
+
+    private static getIcons() {
+        // get favicon link elements
+        let elms = Favicon.getLinks();
+        // if link element
+        if (elms.length === 0) {
+            elms = [window.document.createElement("link")];
+            elms[0].setAttribute("rel", "icon");
+            window.document.getElementsByTagName("head")[0].appendChild(elms[0]);
+        }
+
+        elms.forEach(item => {
+            item.setAttribute("type", "image/png");
+        });
+        return elms;
+    }
+}
diff --git a/src/vector/platform/VectorBasePlatform.js b/src/vector/platform/VectorBasePlatform.js
index 3b8d3c2c2d..3050fef9ad 100644
--- a/src/vector/platform/VectorBasePlatform.js
+++ b/src/vector/platform/VectorBasePlatform.js
@@ -24,7 +24,7 @@ import { _t } from 'matrix-react-sdk/src/languageHandler';
 import dis from 'matrix-react-sdk/src/dispatcher';
 import {getVectorConfig} from "../getconfig";
 
-import Favico from 'favico.js';
+import Favico from '../../favicon';
 
 export const updateCheckStatusEnum = {
     CHECKING: 'CHECKING',
@@ -85,29 +85,9 @@ export default class VectorBasePlatform extends BasePlatform {
                 bgColor = "#f00";
             }
 
-            const doUpdate = () => {
-                this.favicon.badge(notif, {
-                    bgColor: bgColor,
-                });
-            };
-
-            doUpdate();
-
-            // HACK: Workaround for Chrome 78+ and dependency incompatibility.
-            // The library we use doesn't appear to work in Chrome 78, likely due to their
-            // changes surrounding tab behaviour. Tabs went through a bit of a redesign and
-            // restructuring in Chrome 78, so it's not terribly surprising that the library
-            // doesn't work correctly. The library we use hasn't been updated in years and
-            // does not look easy to fix/fork ourselves - we might as well write our own that
-            // doesn't include animation/webcam/etc support. However, that's a bit difficult
-            // so for now we'll just trigger the update twice.
-            //
-            // Note that trying to reproduce the problem in isolation doesn't seem to work:
-            // see https://gist.github.com/turt2live/5ab87919918adbfd7cfb8f1ad10f2409 for
-            // an example (you'll need your own web server to host that).
-            if (window.chrome) {
-                doUpdate();
-            }
+            this.favicon.badge(notif, {
+                bgColor: bgColor,
+            });
         } catch (e) {
             console.warn(`Failed to set badge count: ${e.message}`);
         }
diff --git a/yarn.lock b/yarn.lock
index b27bdebbd9..2fdb208e2e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4284,11 +4284,6 @@ fast-levenshtein@~2.0.6:
   resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
   integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
 
-favico.js@^0.3.10:
-  version "0.3.10"
-  resolved "https://registry.yarnpkg.com/favico.js/-/favico.js-0.3.10.tgz#80586e27a117f24a8d51c18a99bdc714d4339301"
-  integrity sha1-gFhuJ6EX8kqNUcGKmb3HFNQzkwE=
-
 faye-websocket@^0.10.0:
   version "0.10.0"
   resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4"