diff --git a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx
index 452a52be5150ef491ce6e5aed9964797f8fc5c3b..37fbead141f83fd1e094576135a0b19f4e5639b5 100644
--- a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx
+++ b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx
@@ -4,6 +4,7 @@ import React, { Component } from "react";
 import { connect } from "react-redux";
 import { push } from "react-router-redux";
 import title from "metabase/hoc/Title";
+import titleWithLoadingTime from "metabase/hoc/TitleWithLoadingTime";
 
 import Dashboard from "metabase/dashboard/components/Dashboard";
 
@@ -21,6 +22,7 @@ import {
   getEditingParameter,
   getParameters,
   getParameterValues,
+  getLoadingStartTime,
 } from "../selectors";
 import { getDatabases, getMetadata } from "metabase/selectors/metadata";
 import { getUserIsAdmin } from "metabase/selectors/user";
@@ -47,6 +49,7 @@ const mapStateToProps = (state, props) => {
     parameters: getParameters(state, props),
     parameterValues: getParameterValues(state, props),
     metadata: getMetadata(state),
+    loadingStartTime: getLoadingStartTime(state),
   };
 };
 
@@ -67,6 +70,7 @@ type DashboardAppState = {
   mapDispatchToProps,
 )
 @title(({ dashboard }) => dashboard && dashboard.name)
+@titleWithLoadingTime("loadingStartTime")
 // NOTE: should use DashboardControls and DashboardData HoCs here?
 export default class DashboardApp extends Component {
   state: DashboardAppState = {
diff --git a/frontend/src/metabase/dashboard/dashboard.js b/frontend/src/metabase/dashboard/dashboard.js
index ead731e6cd8fe61468a24309253a0067e203919f..f9e5b953e6f28d084e1e1196c41179e57503d336 100644
--- a/frontend/src/metabase/dashboard/dashboard.js
+++ b/frontend/src/metabase/dashboard/dashboard.js
@@ -1168,6 +1168,52 @@ const parameterValues = handleActions(
   {},
 );
 
+const loadingDashCards = handleActions(
+  {
+    [FETCH_DASHBOARD]: {
+      next: (state, { payload }) => ({
+        ...state,
+        dashcardIds: Object.values(payload.entities.dashcard || {}).map(
+          dc => dc.id,
+        ),
+      }),
+    },
+    [FETCH_DASHBOARD_CARD_DATA]: {
+      next: state => ({
+        ...state,
+        loadingIds: state.dashcardIds,
+        startTime:
+          state.dashcardIds.length > 0 &&
+          // check that performance is defined just in case
+          typeof performance === "object"
+            ? performance.now()
+            : null,
+      }),
+    },
+    [FETCH_CARD_DATA]: {
+      next: (state, { payload: { dashcard_id } }) => {
+        const loadingIds = state.loadingIds.filter(id => id !== dashcard_id);
+        return {
+          ...state,
+          loadingIds,
+          ...(loadingIds.length === 0 ? { startTime: null } : {}),
+        };
+      },
+    },
+    [CANCEL_FETCH_CARD_DATA]: {
+      next: (state, { payload: { dashcard_id } }) => {
+        const loadingIds = state.loadingIds.filter(id => id !== dashcard_id);
+        return {
+          ...state,
+          loadingIds,
+          ...(loadingIds.length === 0 ? { startTime: null } : {}),
+        };
+      },
+    },
+  },
+  { dashcardIds: [], loadingIds: [], startTime: null },
+);
+
 export default combineReducers({
   dashboardId,
   isEditing,
@@ -1179,4 +1225,5 @@ export default combineReducers({
   dashcardData,
   slowCards,
   parameterValues,
+  loadingDashCards,
 });
diff --git a/frontend/src/metabase/dashboard/selectors.js b/frontend/src/metabase/dashboard/selectors.js
index f9b33c19daec8e0647f6c6e667bff679a8d834ec..edcb7cec0bcf08728bbe72cb93ebf53e42a21175 100644
--- a/frontend/src/metabase/dashboard/selectors.js
+++ b/frontend/src/metabase/dashboard/selectors.js
@@ -44,6 +44,8 @@ export const getCardData = state => state.dashboard.dashcardData;
 export const getSlowCards = state => state.dashboard.slowCards;
 export const getCardIdList = state => state.dashboard.cardList;
 export const getParameterValues = state => state.dashboard.parameterValues;
+export const getLoadingStartTime = state =>
+  state.dashboard.loadingDashCards.startTime;
 
 export const getDashboard = createSelector(
   [getDashboardId, getDashboards],
diff --git a/frontend/src/metabase/hoc/Title.jsx b/frontend/src/metabase/hoc/Title.jsx
index 63f7d6d3e5eb8b253e04f7b63b180199e423fc15..8addf691a2c47c747ca1db27614085d3e66c2e24 100644
--- a/frontend/src/metabase/hoc/Title.jsx
+++ b/frontend/src/metabase/hoc/Title.jsx
@@ -4,36 +4,14 @@ import _ from "underscore";
 
 const componentStack = [];
 
-let SEPARATOR = " · ";
-let HIERARCHICAL = true;
-let BASE_NAME = null;
-
-export const setSeparator = separator => (SEPARATOR = separator);
-export const setHierarchical = hierarchical => (HIERARCHICAL = hierarchical);
-export const setBaseName = baseName => (BASE_NAME = baseName);
+const SEPARATOR = " · ";
 
 const updateDocumentTitle = _.debounce(() => {
-  if (HIERARCHICAL) {
-    document.title = componentStack
-      .map(component => component._documentTitle)
-      .filter(title => title)
-      .reverse()
-      .join(SEPARATOR);
-  } else {
-    // update with the top-most title
-    for (let i = componentStack.length - 1; i >= 0; i--) {
-      let title = componentStack[i]._documentTitle;
-      if (title) {
-        if (BASE_NAME) {
-          title += SEPARATOR + BASE_NAME;
-        }
-        if (document.title !== title) {
-          document.title = title;
-        }
-        break;
-      }
-    }
-  }
+  document.title = componentStack
+    .map(component => component._documentTitle)
+    .filter(title => title)
+    .reverse()
+    .join(SEPARATOR);
 });
 
 const title = documentTitleOrGetter => ComposedComponent =>
@@ -64,7 +42,19 @@ const title = documentTitleOrGetter => ComposedComponent =>
       if (typeof documentTitleOrGetter === "string") {
         this._documentTitle = documentTitleOrGetter;
       } else if (typeof documentTitleOrGetter === "function") {
-        this._documentTitle = documentTitleOrGetter(this.props);
+        const result = documentTitleOrGetter(this.props);
+        if (result == null) {
+          // title functions might return null before data is loaded
+          this._documentTitle = "";
+        } else if (typeof result === "string") {
+          this._documentTitle = result;
+        } else if (typeof result === "object") {
+          // The getter can return an object with a `refresh` promise along with
+          // the title. When that promise resolves, we call
+          // `documentTitleOrGetter` again.
+          this._documentTitle = result.title;
+          result.refresh.then(() => this._updateDocumentTitle());
+        }
       }
       updateDocumentTitle();
     }
diff --git a/frontend/src/metabase/hoc/TitleWithLoadingTime.jsx b/frontend/src/metabase/hoc/TitleWithLoadingTime.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..cb277a065a9695b6a591df227c45c0f58c5b2525
--- /dev/null
+++ b/frontend/src/metabase/hoc/TitleWithLoadingTime.jsx
@@ -0,0 +1,27 @@
+import React from "react";
+
+import { delay } from "metabase/lib/promise";
+import title from "metabase/hoc/Title";
+
+const SECONDS_UNTIL_DISPLAY = 10;
+
+export default startTimePropName => ComposedComponent =>
+  title(({ [startTimePropName]: startTime }) => {
+    if (startTime == null) {
+      return "";
+    }
+    const totalSeconds = (performance.now() - startTime) / 1000;
+    const title =
+      totalSeconds < SECONDS_UNTIL_DISPLAY
+        ? "" // don't display the title until SECONDS_UNTIL_DISPLAY have elapsed
+        : [totalSeconds / 60, totalSeconds % 60] // minutes, seconds
+            .map(Math.floor) // round both down
+            .map(x => (x < 10 ? `0${x}` : `${x}`)) // pad with "0" to two digits
+            .join(":"); // separate with ":"
+    return { title, refresh: delay(100) };
+  })(
+    // remove the start time prop to prevent affecting child components
+    ({ [startTimePropName]: _removed, ...props }) => (
+      <ComposedComponent {...props} />
+    ),
+  );
diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
index b153d54998adff6bf7504b98590b2885a7fd710b..54f3412ff1c2587bb4435b18d8a291377def7fd0 100644
--- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
+++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
@@ -14,6 +14,7 @@ import View from "../components/view/View";
 // import Notebook from "../components/notebook/Notebook";
 
 import title from "metabase/hoc/Title";
+import titleWithLoadingTime from "metabase/hoc/TitleWithLoadingTime";
 
 import {
   getCard,
@@ -42,6 +43,7 @@ import {
   getQuestion,
   getOriginalQuestion,
   getSettings,
+  getQueryStartTime,
   getRawSeries,
   getQuestionAlerts,
   getVisualizationSettings,
@@ -140,6 +142,7 @@ const mapStateToProps = (state, props) => {
       state,
       props,
     ),
+    queryStartTime: getQueryStartTime(state),
   };
 };
 
@@ -153,6 +156,7 @@ const mapDispatchToProps = {
   mapDispatchToProps,
 )
 @title(({ card }) => (card && card.name) || t`Question`)
+@titleWithLoadingTime("queryStartTime")
 @fitViewport
 export default class QueryBuilder extends Component {
   timeout: any;
diff --git a/frontend/src/metabase/query_builder/reducers.js b/frontend/src/metabase/query_builder/reducers.js
index 884e4872ee86394093343dd6053768cd7d760e35..3e9d03f019f7597d3c0b2e58b83c1b1f29d262d5 100644
--- a/frontend/src/metabase/query_builder/reducers.js
+++ b/frontend/src/metabase/query_builder/reducers.js
@@ -303,6 +303,16 @@ export const cancelQueryDeferred = handleActions(
   null,
 );
 
+export const queryStartTime = handleActions(
+  {
+    [RUN_QUERY]: { next: (state, { payload }) => performance.now() },
+    [CANCEL_QUERY]: { next: (state, { payload }) => null },
+    [QUERY_COMPLETED]: { next: (state, { payload }) => null },
+    [QUERY_ERRORED]: { next: (state, { payload }) => null },
+  },
+  null,
+);
+
 export const parameterValues = handleActions(
   {
     [SET_PARAMETER_VALUE]: {
diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js
index 945e19a89e598be6de0774f3e8a8dc9ddb9b4e23..6844b6754a43aa94be8a521c1944ddb81299eceb 100644
--- a/frontend/src/metabase/query_builder/selectors.js
+++ b/frontend/src/metabase/query_builder/selectors.js
@@ -50,6 +50,8 @@ export const getSettings = state => state.settings.values;
 
 export const getIsNew = state => state.qb.card && !state.qb.card.id;
 
+export const getQueryStartTime = state => state.qb.queryStartTime;
+
 export const getDatabaseId = createSelector(
   [getCard],
   card => card && card.dataset_query && card.dataset_query.database,
diff --git a/frontend/test/metabase/scenarios/pulse.cy.spec.js b/frontend/test/metabase/scenarios/pulse.cy.spec.js
index ecc6015f43c00b6cbaf37a98b5b211db588baaf9..65183dc2860d5ec01b7c9faa0e8312b175cab295 100644
--- a/frontend/test/metabase/scenarios/pulse.cy.spec.js
+++ b/frontend/test/metabase/scenarios/pulse.cy.spec.js
@@ -32,7 +32,7 @@ describe("pulse", () => {
     cy.visit("/pulse/create");
 
     cy.get('[placeholder="Important metrics"]')
-      .wait(10)
+      .wait(100)
       .type("pulse title");
 
     cy.contains("Select a question").click();