diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15aeb7cc..9a851bef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 
 ## [Unreleased]
+### Added
+- New option to optimize timeline rendering to make the site more responsive (enabled by default)
+
 ## [Unreleased patch]
 
 ### Fixed
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index 63e0ceba..7fabc963 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -80,6 +80,8 @@
         class="video"
         :attachment="attachment"
         :controls="allowPlay"
+        @play="$emit('play')"
+        @pause="$emit('pause')"
       />
       <i
         v-if="!allowPlay"
@@ -93,6 +95,8 @@
       :alt="attachment.description"
       :title="attachment.description"
       controls
+      @play="$emit('play')"
+      @pause="$emit('pause')"
     />
 
     <div
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 45fb2bf6..069c0b40 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -44,7 +44,8 @@ const conversation = {
     'isPage',
     'pinnedStatusIdsObject',
     'inProfile',
-    'profileUserId'
+    'profileUserId',
+    'virtualHidden'
   ],
   created () {
     if (this.isPage) {
@@ -52,6 +53,13 @@ const conversation = {
     }
   },
   computed: {
+    hideStatus () {
+      if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
+        return this.virtualHidden && this.$refs.statusComponent[0].suspendable
+      } else {
+        return this.virtualHidden
+      }
+    },
     status () {
       return this.$store.state.statuses.allStatusesObject[this.statusId]
     },
@@ -102,6 +110,10 @@ const conversation = {
     },
     isExpanded () {
       return this.expanded || this.isPage
+    },
+    hiddenStyle () {
+      const height = (this.status && this.status.virtualHeight) || '120px'
+      return this.virtualHidden ? { height } : {}
     }
   },
   components: {
@@ -121,6 +133,12 @@ const conversation = {
       if (value) {
         this.fetchConversation()
       }
+    },
+    virtualHidden (value) {
+      this.$store.dispatch(
+        'setVirtualHeight',
+        { statusId: this.statusId, height: `${this.$el.clientHeight}px` }
+      )
     }
   },
   methods: {
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 997a4d10..e0b9fcc5 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,5 +1,7 @@
 <template>
   <div
+    v-if="!hideStatus"
+    :style="hiddenStyle"
     class="Conversation"
     :class="{ '-expanded' : isExpanded, 'panel' : isExpanded }"
   >
@@ -18,6 +20,7 @@
     <status
       v-for="status in conversation"
       :key="status.id"
+      ref="statusComponent"
       :inline-expanded="collapsable && isExpanded"
       :statusoid="status"
       :expandable="!isExpanded"
@@ -33,6 +36,10 @@
       @toggleExpanded="toggleExpanded"
     />
   </div>
+  <div
+    v-else
+    :style="hiddenStyle"
+  />
 </template>
 
 <script src="./conversation.js"></script>
@@ -53,8 +60,8 @@
     .conversation-status {
       border-color: $fallback--border;
       border-color: var(--border, $fallback--border);
-      border-left: 4px solid $fallback--cRed;
-      border-left: 4px solid var(--cRed, $fallback--cRed);
+      border-left-color: $fallback--cRed;
+      border-left-color: var(--cRed, $fallback--cRed);
     }
 
     .conversation-status:last-child {
diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js
index abcf0455..11627e9c 100644
--- a/src/components/react_button/react_button.js
+++ b/src/components/react_button/react_button.js
@@ -1,5 +1,4 @@
 import Popover from '../popover/popover.vue'
-import { mapGetters } from 'vuex'
 
 const ReactButton = {
   props: ['status'],
@@ -35,7 +34,9 @@ const ReactButton = {
       }
       return this.$store.state.instance.emoji || []
     },
-    ...mapGetters(['mergedConfig'])
+    mergedConfig () {
+      return this.$store.getters.mergedConfig
+    }
   }
 }
 
diff --git a/src/components/retweet_button/retweet_button.js b/src/components/retweet_button/retweet_button.js
index d9a0f92e..5a41f22d 100644
--- a/src/components/retweet_button/retweet_button.js
+++ b/src/components/retweet_button/retweet_button.js
@@ -1,4 +1,3 @@
-import { mapGetters } from 'vuex'
 
 const RetweetButton = {
   props: ['status', 'loggedIn', 'visibility'],
@@ -28,7 +27,9 @@ const RetweetButton = {
         'animate-spin': this.animated
       }
     },
-    ...mapGetters(['mergedConfig'])
+    mergedConfig () {
+      return this.$store.getters.mergedConfig
+    }
   }
 }
 
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index 7f06d0bd..13482de7 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -58,6 +58,11 @@
             {{ $t('settings.emoji_reactions_on_timeline') }}
           </Checkbox>
         </li>
+        <li>
+          <Checkbox v-model="virtualScrolling">
+            {{ $t('settings.virtual_scrolling') }}
+          </Checkbox>
+        </li>
       </ul>
     </div>
 
diff --git a/src/components/status/status.js b/src/components/status/status.js
index d263da68..cd6e2f72 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -15,7 +15,6 @@ import generateProfileLink from 'src/services/user_profile_link_generator/user_p
 import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
 import { muteWordHits } from '../../services/status_parser/status_parser.js'
 import { unescape, uniqBy } from 'lodash'
-import { mapGetters, mapState } from 'vuex'
 
 const Status = {
   name: 'Status',
@@ -54,6 +53,8 @@ const Status = {
       replying: false,
       unmuted: false,
       userExpanded: false,
+      mediaPlaying: [],
+      suspendable: true,
       error: null
     }
   },
@@ -157,7 +158,7 @@ const Status = {
       return this.mergedConfig.hideFilteredStatuses
     },
     hideStatus () {
-      return this.deleted || (this.muted && this.hideFilteredStatuses)
+      return this.deleted || (this.muted && this.hideFilteredStatuses) || this.virtualHidden
     },
     isFocused () {
       // retweet or root of an expanded conversation
@@ -207,11 +208,18 @@ const Status = {
     hidePostStats () {
       return this.mergedConfig.hidePostStats
     },
-    ...mapGetters(['mergedConfig']),
-    ...mapState({
-      betterShadow: state => state.interface.browserSupport.cssFilter,
-      currentUser: state => state.users.currentUser
-    })
+    currentUser () {
+      return this.$store.state.users.currentUser
+    },
+    betterShadow () {
+      return this.$store.state.interface.browserSupport.cssFilter
+    },
+    mergedConfig () {
+      return this.$store.getters.mergedConfig
+    },
+    isSuspendable () {
+      return !this.replying && this.mediaPlaying.length === 0
+    }
   },
   methods: {
     visibilityIcon (visibility) {
@@ -251,6 +259,12 @@ const Status = {
     },
     generateUserProfileLink (id, name) {
       return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
+    },
+    addMediaPlaying (id) {
+      this.mediaPlaying.push(id)
+    },
+    removeMediaPlaying (id) {
+      this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id)
     }
   },
   watch: {
@@ -280,6 +294,9 @@ const Status = {
       if (this.isFocused && this.statusFromGlobalRepository.favoritedBy && this.statusFromGlobalRepository.favoritedBy.length !== num) {
         this.$store.dispatch('fetchFavs', this.status.id)
       }
+    },
+    'isSuspendable': function (val) {
+      this.suspendable = val
     }
   },
   filters: {
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 8d292d3f..c92d870b 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -25,6 +25,11 @@ $status-margin: 0.75em;
     --icon: var(--selectedPostIcon, $fallback--icon);
   }
 
+  &.-conversation {
+    border-left-width: 4px;
+    border-left-style: solid;
+  }
+
   .status-container {
     display: flex;
     padding: $status-margin;
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 282ad37d..aa67e433 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -16,7 +16,7 @@
       />
     </div>
     <template v-if="muted && !isPreview">
-      <div class="status-csontainer muted">
+      <div class="status-container muted">
         <small class="status-username">
           <i
             v-if="muted && retweet"
@@ -227,6 +227,7 @@
                     </span>
                   </a>
                 </StatusPopover>
+
                 <span
                   v-else
                   class="reply-to-no-popover"
@@ -272,6 +273,8 @@
             :no-heading="noHeading"
             :highlight="highlight"
             :focused="isFocused"
+            @mediaplay="addMediaPlaying($event)"
+            @mediapause="removeMediaPlaying($event)"
           />
 
           <transition name="fade">
@@ -354,6 +357,7 @@
               @onSuccess="clearError"
             />
           </div>
+
         </div>
       </div>
       <div
@@ -376,4 +380,5 @@
 </template>
 
 <script src="./status.js" ></script>
+
 <style src="./status.scss" lang="scss"></style>
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 76fe3278..f7fb5ee2 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -107,6 +107,8 @@
         :attachment="attachment"
         :allow-play="true"
         :set-media="setMedia()"
+        @play="$emit('mediaplay', attachment.id)"
+        @pause="$emit('mediapause', attachment.id)"
       />
       <gallery
         v-if="galleryAttachments.length > 0"
diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js
index ab40bbd7..8044e994 100644
--- a/src/components/still-image/still-image.js
+++ b/src/components/still-image/still-image.js
@@ -19,14 +19,16 @@ const StillImage = {
   },
   methods: {
     onLoad () {
-      this.imageLoadHandler && this.imageLoadHandler(this.$refs.src)
+      const image = this.$refs.src
+      if (!image) return
+      this.imageLoadHandler && this.imageLoadHandler(image)
       const canvas = this.$refs.canvas
       if (!canvas) return
-      const width = this.$refs.src.naturalWidth
-      const height = this.$refs.src.naturalHeight
+      const width = image.naturalWidth
+      const height = image.naturalHeight
       canvas.width = width
       canvas.height = height
-      canvas.getContext('2d').drawImage(this.$refs.src, 0, 0, width, height)
+      canvas.getContext('2d').drawImage(image, 0, 0, width, height)
     },
     onError () {
       this.imageLoadError && this.imageLoadError()
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 5a7f7a78..17680542 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -33,7 +33,8 @@ const Timeline = {
     return {
       paused: false,
       unfocused: false,
-      bottomedOut: false
+      bottomedOut: false,
+      virtualScrollIndex: 0
     }
   },
   components: {
@@ -78,6 +79,16 @@ const Timeline = {
     },
     pinnedStatusIdsObject () {
       return keyBy(this.pinnedStatusIds)
+    },
+    statusesToDisplay () {
+      const amount = this.timeline.visibleStatuses.length
+      const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80))
+      const min = Math.max(0, this.virtualScrollIndex - statusesPerSide)
+      const max = Math.min(amount, this.virtualScrollIndex + statusesPerSide)
+      return this.timeline.visibleStatuses.slice(min, max).map(_ => _.id)
+    },
+    virtualScrollingEnabled () {
+      return this.$store.getters.mergedConfig.virtualScrolling
     }
   },
   created () {
@@ -85,7 +96,7 @@ const Timeline = {
     const credentials = store.state.users.currentUser.credentials
     const showImmediately = this.timeline.visibleStatuses.length === 0
 
-    window.addEventListener('scroll', this.scrollLoad)
+    window.addEventListener('scroll', this.handleScroll)
 
     if (store.state.api.fetchers[this.timelineName]) { return false }
 
@@ -104,9 +115,10 @@ const Timeline = {
       this.unfocused = document.hidden
     }
     window.addEventListener('keydown', this.handleShortKey)
+    setTimeout(this.determineVisibleStatuses, 250)
   },
   destroyed () {
-    window.removeEventListener('scroll', this.scrollLoad)
+    window.removeEventListener('scroll', this.handleScroll)
     window.removeEventListener('keydown', this.handleShortKey)
     if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
     this.$store.commit('setLoading', { timeline: this.timelineName, value: false })
@@ -146,6 +158,48 @@ const Timeline = {
         }
       })
     }, 1000, this),
+    determineVisibleStatuses () {
+      if (!this.$refs.timeline) return
+      if (!this.virtualScrollingEnabled) return
+
+      const statuses = this.$refs.timeline.children
+      const cappedScrollIndex = Math.max(0, Math.min(this.virtualScrollIndex, statuses.length - 1))
+
+      if (statuses.length === 0) return
+
+      const height = Math.max(document.body.offsetHeight, window.pageYOffset)
+
+      const centerOfScreen = window.pageYOffset + (window.innerHeight * 0.5)
+
+      // Start from approximating the index of some visible status by using the
+      // the center of the screen on the timeline.
+      let approxIndex = Math.floor(statuses.length * (centerOfScreen / height))
+      let err = statuses[approxIndex].getBoundingClientRect().y
+
+      // if we have a previous scroll index that can be used, test if it's
+      // closer than the previous approximation, use it if so
+
+      const virtualScrollIndexY = statuses[cappedScrollIndex].getBoundingClientRect().y
+      if (Math.abs(err) > virtualScrollIndexY) {
+        approxIndex = cappedScrollIndex
+        err = virtualScrollIndexY
+      }
+
+      // if the status is too far from viewport, check the next/previous ones if
+      // they happen to be better
+      while (err < -20 && approxIndex < statuses.length - 1) {
+        err += statuses[approxIndex].offsetHeight
+        approxIndex++
+      }
+      while (err > window.innerHeight + 100 && approxIndex > 0) {
+        approxIndex--
+        err -= statuses[approxIndex].offsetHeight
+      }
+
+      // this status is now the center point for virtual scrolling and visible
+      // statuses will be nearby statuses before and after it
+      this.virtualScrollIndex = approxIndex
+    },
     scrollLoad (e) {
       const bodyBRect = document.body.getBoundingClientRect()
       const height = Math.max(bodyBRect.height, -(bodyBRect.y))
@@ -155,6 +209,10 @@ const Timeline = {
         this.fetchOlderStatuses()
       }
     },
+    handleScroll: throttle(function (e) {
+      this.determineVisibleStatuses()
+      this.scrollLoad(e)
+    }, 200),
     handleVisibilityChange () {
       this.unfocused = document.hidden
     }
diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue
index 2ff933e9..c1e2f44b 100644
--- a/src/components/timeline/timeline.vue
+++ b/src/components/timeline/timeline.vue
@@ -32,7 +32,10 @@
       </div>
     </div>
     <div :class="classes.body">
-      <div class="timeline">
+      <div
+        ref="timeline"
+        class="timeline"
+      >
         <template v-for="statusId in pinnedStatusIds">
           <conversation
             v-if="timeline.statusesObject[statusId]"
@@ -54,6 +57,7 @@
             :collapsable="true"
             :in-profile="inProfile"
             :profile-user-id="userId"
+            :virtual-hidden="virtualScrollingEnabled && !statusesToDisplay.includes(status.id)"
           />
         </template>
       </div>
diff --git a/src/components/video_attachment/video_attachment.js b/src/components/video_attachment/video_attachment.js
index f0ca7e89..107b8985 100644
--- a/src/components/video_attachment/video_attachment.js
+++ b/src/components/video_attachment/video_attachment.js
@@ -3,27 +3,48 @@ const VideoAttachment = {
   props: ['attachment', 'controls'],
   data () {
     return {
-      loopVideo: this.$store.getters.mergedConfig.loopVideo
+      blocksSuspend: false,
+      // Start from true because removing "loop" property seems buggy in Vue
+      hasAudio: true
+    }
+  },
+  computed: {
+    loopVideo () {
+      if (this.$store.getters.mergedConfig.loopVideoSilentOnly) {
+        return !this.hasAudio
+      }
+      return this.$store.getters.mergedConfig.loopVideo
     }
   },
   methods: {
-    onVideoDataLoad (e) {
+    onPlaying (e) {
+      this.setHasAudio(e)
+      if (this.loopVideo) {
+        this.$emit('play', { looping: true })
+        return
+      }
+      this.$emit('play')
+    },
+    onPaused (e) {
+      this.$emit('pause')
+    },
+    setHasAudio (e) {
       const target = e.srcElement || e.target
+      // If hasAudio is false, we've already marked this video to not have audio,
+      // a video can't gain audio out of nowhere so don't bother checking again.
+      if (!this.hasAudio) return
       if (typeof target.webkitAudioDecodedByteCount !== 'undefined') {
         // non-zero if video has audio track
-        if (target.webkitAudioDecodedByteCount > 0) {
-          this.loopVideo = this.loopVideo && !this.$store.getters.mergedConfig.loopVideoSilentOnly
-        }
-      } else if (typeof target.mozHasAudio !== 'undefined') {
-        // true if video has audio track
-        if (target.mozHasAudio) {
-          this.loopVideo = this.loopVideo && !this.$store.getters.mergedConfig.loopVideoSilentOnly
-        }
-      } else if (typeof target.audioTracks !== 'undefined') {
-        if (target.audioTracks.length > 0) {
-          this.loopVideo = this.loopVideo && !this.$store.getters.mergedConfig.loopVideoSilentOnly
-        }
+        if (target.webkitAudioDecodedByteCount > 0) return
       }
+      if (typeof target.mozHasAudio !== 'undefined') {
+        // true if video has audio track
+        if (target.mozHasAudio) return
+      }
+      if (typeof target.audioTracks !== 'undefined') {
+        if (target.audioTracks.length > 0) return
+      }
+      this.hasAudio = false
     }
   }
 }
diff --git a/src/components/video_attachment/video_attachment.vue b/src/components/video_attachment/video_attachment.vue
index 1ffed4e0..a4bf01e8 100644
--- a/src/components/video_attachment/video_attachment.vue
+++ b/src/components/video_attachment/video_attachment.vue
@@ -7,7 +7,8 @@
     :alt="attachment.description"
     :title="attachment.description"
     playsinline
-    @loadeddata="onVideoDataLoad"
+    @playing="onPlaying"
+    @pause="onPaused"
   />
 </template>
 
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 9da12630..01f89503 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -435,6 +435,7 @@
       "false": "no",
       "true": "yes"
     },
+    "virtual_scrolling": "Optimize timeline rendering",
     "fun": "Fun",
     "greentext": "Meme arrows",
     "notifications": "Notifications",
diff --git a/src/modules/config.js b/src/modules/config.js
index 409d77a4..444b8ec7 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -65,7 +65,8 @@ export const defaultState = {
   useContainFit: false,
   greentext: undefined, // instance default
   hidePostStats: undefined, // instance default
-  hideUserStats: undefined // instance default
+  hideUserStats: undefined, // instance default
+  virtualScrolling: undefined // instance default
 }
 
 // caching the instance default properties
diff --git a/src/modules/instance.js b/src/modules/instance.js
index 3fe3bbf3..b3cbffc6 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -41,6 +41,7 @@ const defaultState = {
   sidebarRight: false,
   subjectLineBehavior: 'email',
   theme: 'pleroma-dark',
+  virtualScrolling: true,
 
   // Nasty stuff
   customEmoji: [],
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index e108b2a7..155cc4b9 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -568,6 +568,9 @@ export const mutations = {
   updateStatusWithPoll (state, { id, poll }) {
     const status = state.allStatusesObject[id]
     status.poll = poll
+  },
+  setVirtualHeight (state, { statusId, height }) {
+    state.allStatusesObject[statusId].virtualHeight = height
   }
 }
 
@@ -753,6 +756,9 @@ const statuses = {
           store.commit('addNewStatuses', { statuses: data.statuses })
           return data
         })
+    },
+    setVirtualHeight ({ commit }, { statusId, height }) {
+      commit('setVirtualHeight', { statusId, height })
     }
   },
   mutations
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 34f86f93..1a3495d4 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -540,8 +540,10 @@ const fetchTimeline = ({
 
   const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
   url += `?${queryString}`
+
   let status = ''
   let statusText = ''
+
   let pagination = {}
   return fetch(url, { headers: authHeaders(credentials) })
     .then((data) => {