diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
index b76bae504dd85..3ba37693491a4 100644
--- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
+++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
@@ -2514,6 +2514,215 @@ describe('ReactSuspenseWithNoopRenderer', () => {
     });
   });
 
+  describe('delays transitions when using React.startTranistion', () => {
+    // @gate experimental
+    it('top level render', async () => {
+      function App({page}) {
+        return (
+          <Suspense fallback={<Text text="Loading..." />}>
+            <AsyncText text={page} ms={5000} />
+          </Suspense>
+        );
+      }
+
+      // Initial render.
+      React.unstable_startTransition(() => ReactNoop.render(<App page="A" />));
+
+      expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
+      // Only a short time is needed to unsuspend the initial loading state.
+      Scheduler.unstable_advanceTime(400);
+      await advanceTimers(400);
+      expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
+
+      // Later we load the data.
+      Scheduler.unstable_advanceTime(5000);
+      await advanceTimers(5000);
+      expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
+      expect(Scheduler).toFlushAndYield(['A']);
+      expect(ReactNoop.getChildren()).toEqual([span('A')]);
+
+      // Start transition.
+      React.unstable_startTransition(() => ReactNoop.render(<App page="B" />));
+
+      expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']);
+      Scheduler.unstable_advanceTime(2999);
+      await advanceTimers(2999);
+      // Since the timeout is infinite (or effectively infinite),
+      // we have still not yet flushed the loading state.
+      expect(ReactNoop.getChildren()).toEqual([span('A')]);
+
+      // Later we load the data.
+      Scheduler.unstable_advanceTime(3000);
+      await advanceTimers(3000);
+      expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
+      expect(Scheduler).toFlushAndYield(['B']);
+      expect(ReactNoop.getChildren()).toEqual([span('B')]);
+
+      // Start a long (infinite) transition.
+      React.unstable_startTransition(() => ReactNoop.render(<App page="C" />));
+      expect(Scheduler).toFlushAndYield(['Suspend! [C]', 'Loading...']);
+
+      // Advance past the current (effectively) infinite timeout.
+      // This is enforcing temporary behavior until it's truly infinite.
+      Scheduler.unstable_advanceTime(100000);
+      await advanceTimers(100000);
+      expect(ReactNoop.getChildren()).toEqual([
+        hiddenSpan('B'),
+        span('Loading...'),
+      ]);
+    });
+
+    // @gate experimental
+    it('hooks', async () => {
+      let transitionToPage;
+      function App() {
+        const [page, setPage] = React.useState('none');
+        transitionToPage = setPage;
+        if (page === 'none') {
+          return null;
+        }
+        return (
+          <Suspense fallback={<Text text="Loading..." />}>
+            <AsyncText text={page} ms={5000} />
+          </Suspense>
+        );
+      }
+
+      ReactNoop.render(<App />);
+      expect(Scheduler).toFlushAndYield([]);
+
+      // Initial render.
+      await ReactNoop.act(async () => {
+        React.unstable_startTransition(() => transitionToPage('A'));
+
+        expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
+        // Only a short time is needed to unsuspend the initial loading state.
+        Scheduler.unstable_advanceTime(400);
+        await advanceTimers(400);
+        expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
+      });
+
+      // Later we load the data.
+      Scheduler.unstable_advanceTime(5000);
+      await advanceTimers(5000);
+      expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
+      expect(Scheduler).toFlushAndYield(['A']);
+      expect(ReactNoop.getChildren()).toEqual([span('A')]);
+
+      // Start transition.
+      await ReactNoop.act(async () => {
+        React.unstable_startTransition(() => transitionToPage('B'));
+
+        expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']);
+
+        Scheduler.unstable_advanceTime(2999);
+        await advanceTimers(2999);
+        // Since the timeout is infinite (or effectively infinite),
+        // we have still not yet flushed the loading state.
+        expect(ReactNoop.getChildren()).toEqual([span('A')]);
+      });
+
+      // Later we load the data.
+      Scheduler.unstable_advanceTime(3000);
+      await advanceTimers(3000);
+      expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
+      expect(Scheduler).toFlushAndYield(['B']);
+      expect(ReactNoop.getChildren()).toEqual([span('B')]);
+
+      // Start a long (infinite) transition.
+      await ReactNoop.act(async () => {
+        React.unstable_startTransition(() => transitionToPage('C'));
+
+        expect(Scheduler).toFlushAndYield(['Suspend! [C]', 'Loading...']);
+
+        // Advance past the current effectively infinite timeout.
+        // This is enforcing temporary behavior until it's truly infinite.
+        Scheduler.unstable_advanceTime(100000);
+        await advanceTimers(100000);
+        expect(ReactNoop.getChildren()).toEqual([
+          hiddenSpan('B'),
+          span('Loading...'),
+        ]);
+      });
+    });
+
+    // @gate experimental
+    it('classes', async () => {
+      let transitionToPage;
+      class App extends React.Component {
+        state = {page: 'none'};
+        render() {
+          transitionToPage = page => this.setState({page});
+          const page = this.state.page;
+          if (page === 'none') {
+            return null;
+          }
+          return (
+            <Suspense fallback={<Text text="Loading..." />}>
+              <AsyncText text={page} ms={5000} />
+            </Suspense>
+          );
+        }
+      }
+
+      ReactNoop.render(<App />);
+      expect(Scheduler).toFlushAndYield([]);
+
+      // Initial render.
+      await ReactNoop.act(async () => {
+        React.unstable_startTransition(() => transitionToPage('A'));
+
+        expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']);
+        // Only a short time is needed to unsuspend the initial loading state.
+        Scheduler.unstable_advanceTime(400);
+        await advanceTimers(400);
+        expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
+      });
+
+      // Later we load the data.
+      Scheduler.unstable_advanceTime(5000);
+      await advanceTimers(5000);
+      expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
+      expect(Scheduler).toFlushAndYield(['A']);
+      expect(ReactNoop.getChildren()).toEqual([span('A')]);
+
+      // Start transition.
+      await ReactNoop.act(async () => {
+        React.unstable_startTransition(() => transitionToPage('B'));
+
+        expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']);
+        Scheduler.unstable_advanceTime(2999);
+        await advanceTimers(2999);
+        // Since the timeout is infinite (or effectively infinite),
+        // we have still not yet flushed the loading state.
+        expect(ReactNoop.getChildren()).toEqual([span('A')]);
+      });
+
+      // Later we load the data.
+      Scheduler.unstable_advanceTime(3000);
+      await advanceTimers(3000);
+      expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
+      expect(Scheduler).toFlushAndYield(['B']);
+      expect(ReactNoop.getChildren()).toEqual([span('B')]);
+
+      // Start a long (infinite) transition.
+      await ReactNoop.act(async () => {
+        React.unstable_startTransition(() => transitionToPage('C'));
+
+        expect(Scheduler).toFlushAndYield(['Suspend! [C]', 'Loading...']);
+
+        // Advance past the current effectively infinite timeout.
+        // This is enforcing temporary behavior until it's truly infinite.
+        Scheduler.unstable_advanceTime(100000);
+        await advanceTimers(100000);
+        expect(ReactNoop.getChildren()).toEqual([
+          hiddenSpan('B'),
+          span('Loading...'),
+        ]);
+      });
+    });
+  });
+
   // @gate experimental
   it('disables suspense config when nothing is passed to withSuspenseConfig', async () => {
     function App({page}) {
diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js
index b06b59a10ab9a..50285328818b7 100644
--- a/packages/react/index.classic.fb.js
+++ b/packages/react/index.classic.fb.js
@@ -46,6 +46,8 @@ export {
   useTransition as unstable_useTransition,
   useDeferredValue,
   useDeferredValue as unstable_useDeferredValue,
+  startTransition,
+  startTransition as unstable_startTransition,
   SuspenseList,
   SuspenseList as unstable_SuspenseList,
   unstable_withSuspenseConfig,
diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js
index f5332574a88b4..ff00b13333da8 100644
--- a/packages/react/index.experimental.js
+++ b/packages/react/index.experimental.js
@@ -42,6 +42,7 @@ export {
   // exposeConcurrentModeAPIs
   useTransition as unstable_useTransition,
   useDeferredValue as unstable_useDeferredValue,
+  startTransition as unstable_startTransition,
   SuspenseList as unstable_SuspenseList,
   unstable_withSuspenseConfig,
   // enableBlocksAPI
diff --git a/packages/react/index.js b/packages/react/index.js
index a02725917e8bc..ebbb6cf42e6ba 100644
--- a/packages/react/index.js
+++ b/packages/react/index.js
@@ -72,6 +72,8 @@ export {
   createFactory,
   useTransition,
   useTransition as unstable_useTransition,
+  startTransition,
+  startTransition as unstable_startTransition,
   useDeferredValue,
   useDeferredValue as unstable_useDeferredValue,
   SuspenseList,
diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js
index 916e6dcebe1f6..fb2e1dcbef4f2 100644
--- a/packages/react/index.modern.fb.js
+++ b/packages/react/index.modern.fb.js
@@ -45,6 +45,8 @@ export {
   useTransition as unstable_useTransition,
   useDeferredValue,
   useDeferredValue as unstable_useDeferredValue,
+  startTransition,
+  startTransition as unstable_startTransition,
   SuspenseList,
   SuspenseList as unstable_SuspenseList,
   unstable_withSuspenseConfig,
diff --git a/packages/react/src/React.js b/packages/react/src/React.js
index db7cf977ff9c5..bd737472a5eb8 100644
--- a/packages/react/src/React.js
+++ b/packages/react/src/React.js
@@ -58,6 +58,7 @@ import {
 import {createMutableSource} from './ReactMutableSource';
 import ReactSharedInternals from './ReactSharedInternals';
 import {createFundamental} from './ReactFundamental';
+import {startTransition} from './ReactStartTransition';
 
 // TODO: Move this branching into the other module instead and just re-export.
 const createElement = __DEV__ ? createElementWithValidation : createElementProd;
@@ -107,6 +108,7 @@ export {
   createFactory,
   // Concurrent Mode
   useTransition,
+  startTransition,
   useDeferredValue,
   REACT_SUSPENSE_LIST_TYPE as SuspenseList,
   REACT_LEGACY_HIDDEN_TYPE as unstable_LegacyHidden,
diff --git a/packages/react/src/ReactStartTransition.js b/packages/react/src/ReactStartTransition.js
new file mode 100644
index 0000000000000..d17f083c473c7
--- /dev/null
+++ b/packages/react/src/ReactStartTransition.js
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+import ReactCurrentBatchConfig from './ReactCurrentBatchConfig';
+
+// Default to an arbitrarily large timeout. Effectively, this is infinite. The
+// eventual goal is to never timeout when refreshing already visible content.
+const IndefiniteTimeoutConfig = {timeoutMs: 100000};
+
+export function startTransition(scope: () => void) {
+  const previousConfig = ReactCurrentBatchConfig.suspense;
+  ReactCurrentBatchConfig.suspense = IndefiniteTimeoutConfig;
+  try {
+    scope();
+  } finally {
+    ReactCurrentBatchConfig.suspense = previousConfig;
+  }
+}