diff --git a/res/css/structures/_SpaceHierarchy.scss b/res/css/structures/_SpaceHierarchy.scss
index fc7cbf4496..5735ef016d 100644
--- a/res/css/structures/_SpaceHierarchy.scss
+++ b/res/css/structures/_SpaceHierarchy.scss
@@ -288,6 +288,11 @@ limitations under the License.
                 visibility: visible;
             }
         }
+
+        &.mx_SpaceHierarchy_joining .mx_AccessibleButton {
+            visibility: visible;
+            padding: 4px 18px;
+        }
     }
 
     li.mx_SpaceHierarchy_roomTileWrapper {
diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx
index e97ba54a83..698f24d659 100644
--- a/src/components/structures/SpaceHierarchy.tsx
+++ b/src/components/structures/SpaceHierarchy.tsx
@@ -60,18 +60,15 @@ import { getDisplayAliasForRoom } from "./RoomDirectory";
 import MatrixClientContext from "../../contexts/MatrixClientContext";
 import { useEventEmitterState } from "../../hooks/useEventEmitter";
 import { IOOBData } from "../../stores/ThreepidInviteStore";
+import { awaitRoomDownSync } from "../../utils/RoomUpgrade";
+import RoomViewStore from "../../stores/RoomViewStore";
 
 interface IProps {
     space: Room;
     initialText?: string;
     additionalButtons?: ReactNode;
-    showRoom(
-        cli: MatrixClient,
-        hierarchy: RoomHierarchy,
-        roomId: string,
-        autoJoin?: boolean,
-        roomType?: RoomType,
-    ): void;
+    showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, roomType?: RoomType): void;
+    joinRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): void;
 }
 
 interface ITileProps {
@@ -80,7 +77,8 @@ interface ITileProps {
     selected?: boolean;
     numChildRooms?: number;
     hasPermissions?: boolean;
-    onViewRoomClick(autoJoin: boolean, roomType: RoomType): void;
+    onViewRoomClick(): void;
+    onJoinRoomClick(): void;
     onToggleClick?(): void;
 }
 
@@ -91,31 +89,50 @@ const Tile: React.FC<ITileProps> = ({
     hasPermissions,
     onToggleClick,
     onViewRoomClick,
+    onJoinRoomClick,
     numChildRooms,
     children,
 }) => {
     const cli = useContext(MatrixClientContext);
-    const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
+    const [joinedRoom, setJoinedRoom] = useState<Room>(() => {
+        const cliRoom = cli.getRoom(room.room_id);
+        return cliRoom?.getMyMembership() === "join" ? cliRoom : null;
+    });
     const joinedRoomName = useEventEmitterState(joinedRoom, "Room.name", room => room?.name);
     const name = joinedRoomName || room.name || room.canonical_alias || room.aliases?.[0]
         || (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
 
     const [showChildren, toggleShowChildren] = useStateToggle(true);
     const [onFocus, isActive, ref] = useRovingTabIndex();
+    const [busy, setBusy] = useState(false);
 
     const onPreviewClick = (ev: ButtonEvent) => {
         ev.preventDefault();
         ev.stopPropagation();
-        onViewRoomClick(false, room.room_type as RoomType);
+        onViewRoomClick();
     };
-    const onJoinClick = (ev: ButtonEvent) => {
+    const onJoinClick = async (ev: ButtonEvent) => {
+        setBusy(true);
         ev.preventDefault();
         ev.stopPropagation();
-        onViewRoomClick(true, room.room_type as RoomType);
+        onJoinRoomClick();
+        setJoinedRoom(await awaitRoomDownSync(cli, room.room_id));
+        setBusy(false);
     };
 
     let button;
-    if (joinedRoom) {
+    if (busy) {
+        button = <AccessibleTooltipButton
+            disabled={true}
+            onClick={onJoinClick}
+            kind="primary_outline"
+            onFocus={onFocus}
+            tabIndex={isActive ? 0 : -1}
+            title={_t("Joining")}
+        >
+            <Spinner w={24} h={24} />
+        </AccessibleTooltipButton>;
+    } else if (joinedRoom) {
         button = <AccessibleButton
             onClick={onPreviewClick}
             kind="primary_outline"
@@ -282,6 +299,7 @@ const Tile: React.FC<ITileProps> = ({
         <AccessibleButton
             className={classNames("mx_SpaceHierarchy_roomTile", {
                 mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
+                mx_SpaceHierarchy_joining: busy,
             })}
             onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
             onKeyDown={onKeyDown}
@@ -296,13 +314,7 @@ const Tile: React.FC<ITileProps> = ({
     </li>;
 };
 
-export const showRoom = (
-    cli: MatrixClient,
-    hierarchy: RoomHierarchy,
-    roomId: string,
-    autoJoin = false,
-    roomType?: RoomType,
-) => {
+export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, roomType?: RoomType): void => {
     const room = hierarchy.roomMap.get(roomId);
 
     // Don't let the user view a room they won't be able to either peek or join:
@@ -317,7 +329,6 @@ export const showRoom = (
     const roomAlias = getDisplayAliasForRoom(room) || undefined;
     dis.dispatch({
         action: "view_room",
-        auto_join: autoJoin,
         should_peek: true,
         _type: "room_directory", // instrumentation
         room_alias: roomAlias,
@@ -332,13 +343,29 @@ export const showRoom = (
     });
 };
 
+export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): void => {
+    // Don't let the user view a room they won't be able to either peek or join:
+    // fail earlier so they don't have to click back to the directory.
+    if (cli.isGuest()) {
+        dis.dispatch({ action: "require_registration" });
+        return;
+    }
+
+    cli.joinRoom(roomId, {
+        viaServers: Array.from(hierarchy.viaMap.get(roomId) || []),
+    }).catch(err => {
+        RoomViewStore.showJoinRoomError(err, roomId);
+    });
+};
+
 interface IHierarchyLevelProps {
     root: IHierarchyRoom;
     roomSet: Set<IHierarchyRoom>;
     hierarchy: RoomHierarchy;
     parents: Set<string>;
     selectedMap?: Map<string, Set<string>>;
-    onViewRoomClick(roomId: string, autoJoin: boolean, roomType?: RoomType): void;
+    onViewRoomClick(roomId: string, roomType?: RoomType): void;
+    onJoinRoomClick(roomId: string): void;
     onToggleClick?(parentId: string, childId: string): void;
 }
 
@@ -373,6 +400,7 @@ export const HierarchyLevel = ({
     parents,
     selectedMap,
     onViewRoomClick,
+    onJoinRoomClick,
     onToggleClick,
 }: IHierarchyLevelProps) => {
     const cli = useContext(MatrixClientContext);
@@ -400,9 +428,8 @@ export const HierarchyLevel = ({
                     room={room}
                     suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
                     selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
-                    onViewRoomClick={(autoJoin, roomType) => {
-                        onViewRoomClick(room.room_id, autoJoin, roomType);
-                    }}
+                    onViewRoomClick={() => onViewRoomClick(room.room_id, room.room_type as RoomType)}
+                    onJoinRoomClick={() => onJoinRoomClick(room.room_id)}
                     hasPermissions={hasPermissions}
                     onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
                 />
@@ -420,9 +447,8 @@ export const HierarchyLevel = ({
                     }).length}
                     suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
                     selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
-                    onViewRoomClick={(autoJoin, roomType) => {
-                        onViewRoomClick(space.room_id, autoJoin, roomType);
-                    }}
+                    onViewRoomClick={() => onViewRoomClick(space.room_id, RoomType.Space)}
+                    onJoinRoomClick={() => onJoinRoomClick(space.room_id)}
                     hasPermissions={hasPermissions}
                     onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
                 >
@@ -433,6 +459,7 @@ export const HierarchyLevel = ({
                         parents={newParents}
                         selectedMap={selectedMap}
                         onViewRoomClick={onViewRoomClick}
+                        onJoinRoomClick={onJoinRoomClick}
                         onToggleClick={onToggleClick}
                     />
                 </Tile>
@@ -696,9 +723,8 @@ const SpaceHierarchy = ({
                             parents={new Set()}
                             selectedMap={selected}
                             onToggleClick={hasPermissions ? onToggleClick : undefined}
-                            onViewRoomClick={(roomId, autoJoin, roomType) => {
-                                showRoom(cli, hierarchy, roomId, autoJoin, roomType);
-                            }}
+                            onViewRoomClick={(roomId, roomType) => showRoom(cli, hierarchy, roomId, roomType)}
+                            onJoinRoomClick={(roomId) => joinRoom(cli, hierarchy, roomId)}
                         />
                     </>;
                 } else if (!hierarchy.canLoadMore) {
diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx
index 2cdf0a7051..25128dd4f0 100644
--- a/src/components/structures/SpaceRoomView.tsx
+++ b/src/components/structures/SpaceRoomView.tsx
@@ -55,7 +55,7 @@ import {
     showSpaceInvite,
     showSpaceSettings,
 } from "../../utils/space";
-import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
+import SpaceHierarchy, { joinRoom, showRoom } from "./SpaceHierarchy";
 import MemberAvatar from "../views/avatars/MemberAvatar";
 import SpaceStore from "../../stores/SpaceStore";
 import FacePile from "../views/elements/FacePile";
@@ -508,7 +508,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
             ) }
         </RoomTopic>
 
-        <SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
+        <SpaceHierarchy space={space} showRoom={showRoom} joinRoom={joinRoom} additionalButtons={addRoomButton} />
     </div>;
 };
 
diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx
index f285222f7b..5955c44bc3 100644
--- a/src/components/views/avatars/RoomAvatar.tsx
+++ b/src/components/views/avatars/RoomAvatar.tsx
@@ -17,6 +17,7 @@ limitations under the License.
 import React, { ComponentProps } from 'react';
 import { Room } from 'matrix-js-sdk/src/models/room';
 import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
+import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
 import classNames from "classnames";
 
 import BaseAvatar from './BaseAvatar';
@@ -83,8 +84,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
         };
     }
 
-    // TODO: type when js-sdk has types
-    private onRoomStateEvents = (ev: any) => {
+    private onRoomStateEvents = (ev: MatrixEvent) => {
         if (!this.props.room ||
             ev.getRoomId() !== this.props.room.roomId ||
             ev.getType() !== 'm.room.avatar'
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 0596f61b44..1fffc04696 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -2930,6 +2930,7 @@
     "Drop file here to upload": "Drop file here to upload",
     "You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.",
     "You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.",
+    "Joining": "Joining",
     "You don't have permission": "You don't have permission",
     "Joined": "Joined",
     "This room is suggested as a good one to join": "This room is suggested as a good one to join",
diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx
index 1a44b4fb32..edcb0aeff3 100644
--- a/src/stores/RoomViewStore.tsx
+++ b/src/stores/RoomViewStore.tsx
@@ -308,7 +308,7 @@ class RoomViewStore extends Store<ActionPayload> {
         }
     }
 
-    private getInvitingUserId(roomId: string): string {
+    private static getInvitingUserId(roomId: string): string {
         const cli = MatrixClientPeg.get();
         const room = cli.getRoom(roomId);
         if (room && room.getMyMembership() === "invite") {
@@ -318,12 +318,7 @@ class RoomViewStore extends Store<ActionPayload> {
         }
     }
 
-    private joinRoomError(payload: ActionPayload) {
-        this.setState({
-            joining: false,
-            joinError: payload.err,
-        });
-        const err = payload.err;
+    public showJoinRoomError(err: Error | MatrixError, roomId: string) {
         let msg = err.message ? err.message : JSON.stringify(err);
         logger.log("Failed to join room:", msg);
 
@@ -335,7 +330,7 @@ class RoomViewStore extends Store<ActionPayload> {
                 { _t("Please contact your homeserver administrator.") }
             </div>;
         } else if (err.httpStatus === 404) {
-            const invitingUserId = this.getInvitingUserId(this.state.roomId);
+            const invitingUserId = RoomViewStore.getInvitingUserId(roomId);
             // only provide a better error message for invites
             if (invitingUserId) {
                 // if the inviting user is on the same HS, there can only be one cause: they left.
@@ -355,6 +350,14 @@ class RoomViewStore extends Store<ActionPayload> {
         });
     }
 
+    private joinRoomError(payload: ActionPayload) {
+        this.setState({
+            joining: false,
+            joinError: payload.err,
+        });
+        this.showJoinRoomError(payload.err, this.state.roomId);
+    }
+
     public reset() {
         this.state = Object.assign({}, INITIAL_STATE);
     }
diff --git a/src/utils/RoomUpgrade.ts b/src/utils/RoomUpgrade.ts
index ba3fb08c9e..b9ea93d7fc 100644
--- a/src/utils/RoomUpgrade.ts
+++ b/src/utils/RoomUpgrade.ts
@@ -25,6 +25,7 @@ import SpaceStore from "../stores/SpaceStore";
 import Spinner from "../components/views/elements/Spinner";
 
 import { logger } from "matrix-js-sdk/src/logger";
+import { MatrixClient } from "matrix-js-sdk/src/client";
 
 interface IProgress {
     roomUpgraded: boolean;
@@ -35,6 +36,23 @@ interface IProgress {
     updateSpacesTotal: number;
 }
 
+export async function awaitRoomDownSync(cli: MatrixClient, roomId: string): Promise<Room> {
+    const room = cli.getRoom(roomId);
+    if (room) return room; // already have the room
+
+    return new Promise<Room>(resolve => {
+        // We have to wait for the js-sdk to give us the room back so
+        // we can more effectively abuse the MultiInviter behaviour
+        // which heavily relies on the Room object being available.
+        const checkForRoomFn = (room: Room) => {
+            if (room.roomId !== roomId) return;
+            resolve(room);
+            cli.off("Room", checkForRoomFn);
+        };
+        cli.on("Room", checkForRoomFn);
+    });
+}
+
 export async function upgradeRoom(
     room: Room,
     targetVersion: string,
@@ -93,24 +111,7 @@ export async function upgradeRoom(
     progressCallback?.(progress);
 
     if (awaitRoom || inviteUsers) {
-        await new Promise<void>(resolve => {
-            // already have the room
-            if (room.client.getRoom(newRoomId)) {
-                resolve();
-                return;
-            }
-
-            // We have to wait for the js-sdk to give us the room back so
-            // we can more effectively abuse the MultiInviter behaviour
-            // which heavily relies on the Room object being available.
-            const checkForRoomFn = (newRoom: Room) => {
-                if (newRoom.roomId !== newRoomId) return;
-                resolve();
-                cli.off("Room", checkForRoomFn);
-            };
-            cli.on("Room", checkForRoomFn);
-        });
-
+        await awaitRoomDownSync(room.client, newRoomId);
         progress.roomSynced = true;
         progressCallback?.(progress);
     }