diff --git a/.env.example b/.env.example
index 9368d8496620..234b0c283726 100644
--- a/.env.example
+++ b/.env.example
@@ -2,6 +2,7 @@
  * Rename this file to `.env` and put your local config in here
  */
 REPORT_IDS=1,2,3
+EXPENSIFY_SITE_ROOT=http://localhost:8080/
 EXPENSIFY_API_ROOT=https://www.expensify.com.dev/api?
 EXPENSIFY_PARTNER_NAME=android
 EXPENSIFY_PARTNER_PASSWORD=c3a9ac418ea3f152aae2
diff --git a/.env.production b/.env.production
index d4019a0a81cf..fc36abbbf049 100644
--- a/.env.production
+++ b/.env.production
@@ -1,4 +1,5 @@
 REPORT_IDS=63212778,63212795,63212764,63212607,63699490
+EXPENSIFY_SITE_ROOT=https://chat.expensify.com/
 EXPENSIFY_API_ROOT=https://www.expensify.com/api?
 EXPENSIFY_PARTNER_NAME=chat-expensify-com
 EXPENSIFY_PARTNER_PASSWORD=e21965746fd75f82bb66
diff --git a/desktop/electron.config.js b/desktop/electron.config.js
index 28beade576c8..2c595bd1b336 100644
--- a/desktop/electron.config.js
+++ b/desktop/electron.config.js
@@ -22,7 +22,8 @@ module.exports = {
     }],
     files: [
         './dist/**/*',
-        './main.js',
+        './desktop/main.js',
+        './desktop/mainESM.js',
         './desktop/ELECTRON_EVENTS.js',
     ]
 };
diff --git a/desktop/index.js b/desktop/index.js
new file mode 100644
index 000000000000..1d496d5eb75c
--- /dev/null
+++ b/desktop/index.js
@@ -0,0 +1,12 @@
+/**
+ * This file is serving as the webpack entry point for the desktop webpack build.
+ * The main process run by electron lives in desktop/main.js.
+ */
+import {AppRegistry} from 'react-native';
+import App from '../src/App';
+
+AppRegistry.registerComponent('App', () => App);
+
+AppRegistry.runApplication('App', {
+    rootTag: document.getElementById('root'),
+});
diff --git a/main.js b/desktop/main.js
similarity index 87%
rename from main.js
rename to desktop/main.js
index ad39e03dcc91..3077701bd2ea 100644
--- a/main.js
+++ b/desktop/main.js
@@ -1,3 +1,5 @@
+import checkForUpdates from '../src/lib/checkForUpdates';
+
 const {
     app,
     BrowserWindow,
@@ -8,7 +10,7 @@ const serve = require('electron-serve');
 const contextMenu = require('electron-context-menu');
 const {autoUpdater} = require('electron-updater');
 const log = require('electron-log');
-const ELECTRON_EVENTS = require('./desktop/ELECTRON_EVENTS');
+const ELECTRON_EVENTS = require('./ELECTRON_EVENTS');
 
 /**
  * Electron main process that handles wrapping the web application.
@@ -16,9 +18,6 @@ const ELECTRON_EVENTS = require('./desktop/ELECTRON_EVENTS');
  * @see: https://www.electronjs.org/docs/tutorial/application-architecture#main-and-renderer-processes
  */
 
-// Interval that we check for new versions of the app
-const UPDATE_INTERVAL = 1000 * 60 * 60;
-
 // TODO: Turn this off, use web-security after alpha launch, currently we receive a CORS issue preventing
 // the electron app from making any API requests.
 app.commandLine.appendSwitch('disable-web-security');
@@ -37,7 +36,7 @@ autoUpdater.logger.transports.file.level = 'info';
 Object.assign(console, log.functions);
 
 const mainWindow = (() => {
-    const loadURL = serve({directory: 'dist'});
+    const loadURL = serve({directory: '../dist'});
 
     return app.whenReady()
         .then(() => {
@@ -95,11 +94,11 @@ const mainWindow = (() => {
         // After initializing and configuring the browser window, load the compiled JavaScript
         .then(browserWindow => loadURL(browserWindow))
 
-        // Check for a new version of the app on launch
-        .then(() => autoUpdater.checkForUpdatesAndNotify())
-
-        // Set a timer to check for new versions of the app
-        .then(() => setInterval(() => autoUpdater.checkForUpdatesAndNotify(), UPDATE_INTERVAL));
+        // Start checking for JS updates
+        .then(() => checkForUpdates({
+            init: () => autoUpdater.checkForUpdatesAndNotify(),
+            update: () => autoUpdater.checkForUpdatesAndNotify(),
+        }));
 });
 
 mainWindow().then(window => window);
diff --git a/desktop/mainESM.js b/desktop/mainESM.js
new file mode 100644
index 000000000000..3d13ebc2574f
--- /dev/null
+++ b/desktop/mainESM.js
@@ -0,0 +1,17 @@
+/**
+ * This file enables us to import our source code (ES6 modules) into desktop/index.js
+ * It's required because Electron uses node.js, which is build using the CommonJS module system, not ES6 modules.
+ *
+ * As a workaround, we're using the ESM library https://www.npmjs.com/package/esm
+ * to allow us to `require` ES6 modules in a CommonJS environment.
+ *
+ * It's also worth noting that node.js v14 has experimental support for ESM out-of-the-box: https://nodejs.org/docs/latest-v14.x/api/esm.html
+ * Once this feature becomes stable and is released as part of a LTS node.js version, AND Electron adopts this node version,
+ * then we can remove this wrapper file and remove ESM as one of our dependencies. Probably early-to-mid 2021.
+ *
+ * We can follow this issue https://github.com/electron/electron/issues/21457 for updates.
+ */
+
+// eslint-disable-next-line no-global-assign
+require = require('esm')(module);
+module.exports = require('./main.js');
diff --git a/index.js b/index.js
index 69303b34d4b6..66337268830f 100644
--- a/index.js
+++ b/index.js
@@ -1,3 +1,7 @@
+/**
+ * Note: This file is the entry point used by React Native for the native platforms.
+ * Webpack uses different entry points for web and desktop, located at web/index.js and desktop/index.js, respectively.
+ */
 /**
  * @format
  */
diff --git a/package-lock.json b/package-lock.json
index 355e50c683ff..15f82155f6be 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7583,6 +7583,11 @@
       "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
       "dev": true
     },
+    "esm": {
+      "version": "3.2.25",
+      "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
+      "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA=="
+    },
     "espree": {
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/espree/-/espree-7.2.0.tgz",
diff --git a/package.json b/package.json
index 3019bb24f171..fdbd1027dfd8 100644
--- a/package.json
+++ b/package.json
@@ -9,11 +9,11 @@
     "android": "react-native run-android",
     "ios": "react-native run-ios",
     "ipad": "react-native run-ios --simulator=\"iPad Pro (12.9-inch) (4th generation)\"",
-    "desktop": "webpack --config webpack.dev.js --platform desktop && electron main.js",
+    "desktop": "webpack --config webpack/webpack.dev.js --platform desktop && electron desktop/mainESM.js",
     "start": "react-native start",
-    "web": "webpack-dev-server --open --config webpack.dev.js",
-    "build": "webpack --config webpack.prod.js",
-    "desktop-build": "webpack --config webpack.prod.js --platform desktop && electron-builder --config desktop/electron.config.js --publish always",
+    "web": "webpack-dev-server --open --config webpack/webpack.dev.js",
+    "build": "webpack --config webpack/webpack.prod.js",
+    "desktop-build": "webpack --config webpack/webpack.prod.js --platform desktop && electron-builder --config desktop/electron.config.js --publish always",
     "ios-build": "fastlane ios build",
     "android-build": "fastlane android build",
     "test": "jest",
@@ -31,6 +31,7 @@
     "electron-log": "^4.2.4",
     "electron-serve": "^1.0.0",
     "electron-updater": "^4.3.4",
+    "esm": "^3.2.25",
     "file-loader": "^6.0.0",
     "html-entities": "^1.3.1",
     "js-libs": "git+https://git@github.com:Expensify/JS-Libs.git#c2a2bb1b0b725b9b0681e71cb99123f9e711ec20",
diff --git a/src/CONFIG.js b/src/CONFIG.js
index a9e2afd392e9..cf3091faed78 100644
--- a/src/CONFIG.js
+++ b/src/CONFIG.js
@@ -5,6 +5,7 @@ export default {
     AUTH_TOKEN_EXPIRATION_TIME: 1000 * 60 * 90,
     EXPENSIFY: {
         API_ROOT: Config.EXPENSIFY_API_ROOT,
+        SITE_ROOT: Config.EXPENSIFY_SITE_ROOT,
         PARTNER_NAME: Config.EXPENSIFY_PARTNER_NAME,
         PARTNER_PASSWORD: Config.EXPENSIFY_PARTNER_PASSWORD,
     },
diff --git a/src/lib/API.js b/src/lib/API.js
index 5f90399f6d68..8f1fc7ccf149 100644
--- a/src/lib/API.js
+++ b/src/lib/API.js
@@ -1,7 +1,7 @@
 import _ from 'underscore';
 import Ion from './Ion';
 import IONKEYS from '../IONKEYS';
-import xhr from './xhr';
+import HttpUtils from './HttpUtils';
 import NetworkConnection from './NetworkConnection';
 import CONFIG from '../CONFIG';
 import * as Pusher from './Pusher/pusher';
@@ -92,7 +92,7 @@ function createLogin(login, password) {
     // Using xhr instead of request becasue request has logic to re-try API commands when we get a 407 authToken expired
     // in the response, and we call CreateLogin after getting a successful resposne to Authenticate so it's unlikely
     // that we'll get a 407.
-    return xhr('CreateLogin', {
+    return HttpUtils.xhr('CreateLogin', {
         authToken,
         partnerName: CONFIG.EXPENSIFY.PARTNER_NAME,
         partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD,
@@ -151,7 +151,7 @@ function request(command, parameters, type = 'post') {
     // with 407 authToken expired. When other api commands fail with this error we call Authenticate
     // to get a new authToken and then fire the original api command again
     if (command === 'Authenticate') {
-        return xhr(command, parameters, type)
+        return HttpUtils.xhr(command, parameters, type)
             .then((response) => {
                 // If we didn't get a 200 response from authenticate we either failed to authenticate with
                 // an expensify login or the login credentials we created after the initial authentication.
@@ -191,7 +191,7 @@ function request(command, parameters, type = 'post') {
 
     // Make the http request, and if we get 407 jsonCode in the response,
     // re-authenticate to get a fresh authToken and make the original http request again
-    return xhr(command, parametersWithAuthToken, type)
+    return HttpUtils.xhr(command, parametersWithAuthToken, type)
         .then((responseData) => {
             // We can end up here if we have queued up many
             // requests and have an expired authToken. In these cases,
@@ -204,7 +204,7 @@ function request(command, parameters, type = 'post') {
             // we re-authenticate and then re-try the original request
             if (responseData.jsonCode === 407 && parametersWithAuthToken.doNotRetry !== true) {
                 reauthenticating = true;
-                return xhr('Authenticate', {
+                return HttpUtils.xhr('Authenticate', {
                     useExpensifyLogin: false,
                     partnerName: CONFIG.EXPENSIFY.PARTNER_NAME,
                     partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD,
@@ -228,7 +228,7 @@ function request(command, parameters, type = 'post') {
                         Ion.merge(IONKEYS.SESSION, _.pick(response, 'authToken'));
                         return response;
                     })
-                    .then(() => xhr(command, parametersWithAuthToken, type))
+                    .then(() => HttpUtils.xhr(command, parametersWithAuthToken, type))
                     .catch((error) => {
                         reauthenticating = false;
                         redirectToSignIn(error.message);
@@ -256,7 +256,7 @@ function processNetworkRequestQueue() {
         // 2. Getting a 200 response back from the API (happens right below)
 
         // Make a simple request every second to see if the API is online again
-        xhr('Get', {doNotRetry: true})
+        HttpUtils.xhr('Get', {doNotRetry: true})
             .then(() => NetworkConnection.setOfflineStatus(false));
         return;
     }
diff --git a/src/lib/xhr.js b/src/lib/HttpUtils.js
similarity index 51%
rename from src/lib/xhr.js
rename to src/lib/HttpUtils.js
index 1fdb73b932da..dd44fc2a19b4 100644
--- a/src/lib/xhr.js
+++ b/src/lib/HttpUtils.js
@@ -5,19 +5,18 @@ import IONKEYS from '../IONKEYS';
 import NetworkConnection from './NetworkConnection';
 
 /**
- * Makes XHR request
- * @param {String} command the name of the API command
- * @param {Object} data parameters for the API command
- * @param {String} type HTTP request type (get/post)
- * @returns {Promise}
+ * Send an HTTP request, and attempt to resolve the json response.
+ * If there is a network error, we'll set the application offline.
+ *
+ * @param {String} url
+ * @param {String} method
+ * @param {Object} body
+ * @returns {Promise<Response | void>}
  */
-function xhr(command, data, type = 'post') {
-    const formData = new FormData();
-    _.each(data, (val, key) => formData.append(key, val));
-
-    return fetch(`${CONFIG.EXPENSIFY.API_ROOT}command=${command}`, {
-        method: type,
-        body: formData,
+function processHTTPRequest(url, method = 'get', body = null) {
+    return fetch(url, {
+        method,
+        body
     })
         .then(response => response.json())
 
@@ -35,4 +34,37 @@ function xhr(command, data, type = 'post') {
         });
 }
 
-export default xhr;
+/**
+ * Makes XHR request
+ * @param {String} command the name of the API command
+ * @param {Object} data parameters for the API command
+ * @param {String} type HTTP request type (get/post)
+ * @returns {Promise}
+ */
+function xhr(command, data, type = 'post') {
+    const formData = new FormData();
+    _.each(data, (val, key) => formData.append(key, val));
+    return processHTTPRequest(`${CONFIG.EXPENSIFY.API_ROOT}command=${command}`, type, formData);
+}
+
+/**
+ * Just download a file from the web server.
+ *
+ * @param {String} relativePath From the website root, NOT the API root. (no leading slash, ., or ..)
+ * @returns {Promise<Response>}
+ */
+function download(relativePath) {
+    const siteRoot = CONFIG.EXPENSIFY.SITE_ROOT;
+
+    // Strip leading slashes and periods from relative path, if present
+    const strippedRelativePath = relativePath.charAt(0) === '/' || relativePath.charAt(0) === '.'
+        ? relativePath.slice(relativePath.indexOf('/') + 1)
+        : relativePath;
+
+    return processHTTPRequest(`${siteRoot}${strippedRelativePath}`);
+}
+
+export default {
+    download,
+    xhr,
+};
diff --git a/src/lib/Visibility/index.js b/src/lib/Visibility/index.js
index 336d80bf17ea..4368dc15218d 100644
--- a/src/lib/Visibility/index.js
+++ b/src/lib/Visibility/index.js
@@ -1,7 +1,7 @@
 import ELECTRON_EVENTS from '../../../desktop/ELECTRON_EVENTS';
 
 // We conditionally import the ipcRenderer here so that we can
-// communicate with the main Electron process in main.js
+// communicate with the main Electron process
 const ipcRenderer = window.require ? window.require('electron').ipcRenderer : null;
 
 /**
diff --git a/src/lib/checkForUpdates.js b/src/lib/checkForUpdates.js
new file mode 100644
index 000000000000..8467df107bbb
--- /dev/null
+++ b/src/lib/checkForUpdates.js
@@ -0,0 +1,33 @@
+import _ from 'underscore';
+import Ion from './Ion';
+import IONKEYS from '../IONKEYS';
+
+const UPDATE_INTERVAL = 1000 * 60 * 60;
+
+// Subscribe to network status, because we only want to attempt updates when we have a network connection.
+let isOnline;
+Ion.connect({
+    key: IONKEYS.NETWORK,
+    callback: network => isOnline = network && !network.isOffline,
+});
+
+/**
+ * Check for updates every hour, and perform and platform-specific update if there is a network connection.
+ *
+ * @param {Object} platformSpecificUpdater
+ * @param {Function} platformSpecificUpdater.update
+ * @param {?Function} platformSpecificUpdater.init
+ */
+export default function (platformSpecificUpdater) {
+    if (_.isFunction(platformSpecificUpdater.init)) {
+        platformSpecificUpdater.init();
+    }
+
+    // Check for updates every hour
+    setInterval(() => {
+        // We only want to attempt updates if we're online
+        if (isOnline) {
+            platformSpecificUpdater.update();
+        }
+    }, UPDATE_INTERVAL);
+}
diff --git a/web/index.js b/web/index.js
index a95184850f43..8334e5943c07 100644
--- a/web/index.js
+++ b/web/index.js
@@ -1,8 +1,63 @@
 import {AppRegistry} from 'react-native';
 import App from '../src/App';
+import {name as appName} from '../app.json';
+import checkForUpdates from '../src/lib/checkForUpdates';
+import HttpUtils from '../src/lib/HttpUtils';
+import isVisible from '../src/lib/Visibility';
 
 AppRegistry.registerComponent('App', () => App);
+AppRegistry.registerComponent(appName, () => App);
 
 AppRegistry.runApplication('App', {
     rootTag: document.getElementById('root'),
 });
+
+/**
+ * Download the latest app version from the server, and if it is different than the current one,
+ * then refresh. If the page is visibile, prompt the user to refresh.
+ *
+ * @param {String} currentVersion
+ */
+function webUpdate(currentVersion) {
+    HttpUtils.download('version.json')
+        .then(({version: newVersion}) => {
+            if (newVersion !== currentVersion) {
+                if (!isVisible()) {
+                    // Page is hidden, refresh immediately
+                    window.location.reload(true);
+                    return;
+                }
+
+                // Prompt user to refresh the page
+                if (window.confirm('Refresh the page to get the latest updates!')) {
+                    window.location.reload(true);
+                }
+            }
+        });
+}
+
+/**
+ * Create an object whose shape reflects the callbacks used in checkForUpdates.
+ *
+ * @param {String} currentVersion The version of the app that is currently running.
+ * @returns {{init: Function, update: Function}}
+ */
+const webUpdater = currentVersion => ({
+    init: () => {
+        // We want to check for updates and refresh the page if necessary when the app is backgrounded.
+        // That way, it will auto-update silently when they minimize the page,
+        // and we don't bug the user any more than necessary :)
+        window.addEventListener('visibilitychange', () => {
+            if (!isVisible()) {
+                webUpdate(currentVersion);
+            }
+        });
+    },
+    update: () => webUpdate(currentVersion),
+});
+
+// When app loads, get current version
+HttpUtils.download('version.json')
+    .then(({version: currentVersion}) => {
+        checkForUpdates(webUpdater(currentVersion));
+    });
diff --git a/webpack/CustomVersionFilePlugin.js b/webpack/CustomVersionFilePlugin.js
new file mode 100644
index 000000000000..ae6d3da01911
--- /dev/null
+++ b/webpack/CustomVersionFilePlugin.js
@@ -0,0 +1,25 @@
+const fs = require('fs');
+const path = require('path');
+const APP_VERSION = require('../package.json').version;
+
+/**
+ * Simple webpack plugin that writes the app version (from package.json) and the webpack hash to './version.json'
+ */
+class CustomVersionFilePlugin {
+    apply(compiler) {
+        compiler.hooks.done.tap(this.constructor.name, () => new Promise((resolve, reject) => {
+            fs.writeFile(path.join(__dirname, '/../dist/version.json'),
+                JSON.stringify({version: APP_VERSION}),
+                'utf8',
+                (err) => {
+                    if (err) {
+                        reject(err);
+                        return;
+                    }
+                    resolve();
+                });
+        }));
+    }
+}
+
+module.exports = CustomVersionFilePlugin;
diff --git a/webpack.common.js b/webpack/webpack.common.js
similarity index 93%
rename from webpack.common.js
rename to webpack/webpack.common.js
index 02f4efae73d6..8d318b065a7d 100644
--- a/webpack.common.js
+++ b/webpack/webpack.common.js
@@ -2,6 +2,7 @@ const path = require('path');
 const {CleanWebpackPlugin} = require('clean-webpack-plugin');
 const HtmlWebpackPlugin = require('html-webpack-plugin');
 const CopyPlugin = require('copy-webpack-plugin');
+const CustomVersionFilePlugin = require('./CustomVersionFilePlugin');
 
 // Check for a --platform command line argument (default to 'web')
 // If it is 'web', we want to ignore .desktop.js files, and if it is 'desktop', we want to ignore .website.js files.
@@ -11,11 +12,11 @@ const platformExclude = platform === 'web' ? new RegExp(/\.desktop\.js$/) : new
 
 module.exports = {
     entry: {
-        app: './web/index.js',
+        app: platform === 'web' ? './web/index.js' : './desktop/index.js',
     },
     output: {
         filename: '[name]-[hash].bundle.js',
-        path: path.resolve(__dirname, 'dist'),
+        path: path.resolve(__dirname, '../dist'),
     },
     plugins: [
         new CleanWebpackPlugin(),
@@ -31,6 +32,8 @@ module.exports = {
                 {from: 'web/favicon-unread.png'},
             ],
         }),
+
+        new CustomVersionFilePlugin(),
     ],
     module: {
         rules: [
diff --git a/webpack.dev.js b/webpack/webpack.dev.js
similarity index 90%
rename from webpack.dev.js
rename to webpack/webpack.dev.js
index 3cc2a542e6e6..1b06700c0f1d 100644
--- a/webpack.dev.js
+++ b/webpack/webpack.dev.js
@@ -10,7 +10,7 @@ module.exports = merge(common, {
     mode: 'development',
     devtool: 'inline-source-map',
     devServer: {
-        contentBase: path.join(__dirname, 'dist'),
+        contentBase: path.join(__dirname, '../dist'),
         hot: true,
     },
     plugins: [
diff --git a/webpack.prod.js b/webpack/webpack.prod.js
similarity index 81%
rename from webpack.prod.js
rename to webpack/webpack.prod.js
index fcddc6c380f4..6d77be3bf256 100644
--- a/webpack.prod.js
+++ b/webpack/webpack.prod.js
@@ -4,7 +4,7 @@ const {merge} = require('webpack-merge');
 const dotenv = require('dotenv');
 const common = require('./webpack.common.js');
 
-const env = dotenv.config({path: path.resolve(__dirname, '.env.production')}).parsed;
+const env = dotenv.config({path: path.resolve(__dirname, '../.env.production')}).parsed;
 
 module.exports = merge(common, {
     mode: 'production',
