diff --git a/src/media/media_recorder.cpp b/src/media/media_recorder.cpp
index fbb481a337a01c9b631c4b74918e2406ee54c136..7a3c5ee9fa159ef7e3d90cccc2916bb321d36145 100644
--- a/src/media/media_recorder.cpp
+++ b/src/media/media_recorder.cpp
@@ -38,12 +38,17 @@ extern "C" {
 namespace ring {
 
 MediaRecorder::MediaRecorder()
+    : loop_([]{ return true;},
+            [this]{ process(); },
+            []{})
 {}
 
 MediaRecorder::~MediaRecorder()
 {
     if (isRecording_)
         flush();
+    if (loop_.isRunning())
+        loop_.join();
 }
 
 std::string
@@ -112,6 +117,15 @@ MediaRecorder::startRecording()
     ss << std::put_time(&startTime_, "%Y%m%d-%H%M%S");
     filename_ = ss.str();
 
+    if (!frames_.empty()) {
+        RING_WARN() << "Frame queue not empty at beginning of recording, frames will be lost";
+        while (!frames_.empty())
+            frames_.pop();
+    }
+
+    if (!loop_.isRunning())
+        loop_.start();
+
     encoder_.reset(new MediaEncoder);
 
     RING_DBG() << "Start recording '" << getFilename() << "'";
@@ -158,41 +172,13 @@ MediaRecorder::addStream(bool isVideo, bool fromPeer, MediaStream ms)
 int
 MediaRecorder::recordData(AVFrame* frame, bool isVideo, bool fromPeer)
 {
-    std::lock_guard<std::mutex> lk(mutex_);
     if (!isRecording_ || !isReady_)
         return 0;
 
-    int streamIdx = (isVideo ? videoIdx_ : audioIdx_);
-    auto filter = (isVideo ? videoFilter_.get() : audioFilter_.get());
-    if (streamIdx < 0 || !filter) {
-        RING_ERR() << "Specified stream is invalid: "
-            << (fromPeer ? "remote " : "local ") << (isVideo ? "video" : "audio");
-        return -1;
-    }
-
-    // get filter input name if frame needs filtering
-    std::string inputName;
-    if (isVideo && nbReceivedVideoStreams_ == 2)
-        inputName = (fromPeer ? "v:main" : "v:overlay");
-    if (!isVideo && nbReceivedAudioStreams_ == 2)
-        inputName = (fromPeer ? "a:1" : "a:2");
-
-    // new reference because we are changing the timestamp
+    // save a copy of the frame, will be filtered/encoded in another thread
     AVFrame* input = av_frame_clone(frame);
-    const MediaStream& ms = streams_[isVideo][fromPeer];
-    // stream has to start at 0
-    input->pts = input->pts - ms.firstTimestamp;
-
-    if (inputName.empty()) // #nofilters
-        return sendToEncoder(input, streamIdx);
-
-    // empty filter graph output before sending more frames
-    emptyFilterGraph();
-
-    int err = filter->feedInput(input, inputName);
-    av_frame_unref(input);
-
-    return err;
+    frames_.emplace(input, isVideo, fromPeer);
+    return 0;
 }
 
 int
@@ -437,4 +423,55 @@ MediaRecorder::flush()
     return 0;
 }
 
+void
+MediaRecorder::process()
+{
+    std::lock_guard<std::mutex> lk(mutex_);
+    if (!isRecording_ || !isReady_)
+        return;
+
+    while (!frames_.empty()) {
+        auto f = frames_.front();
+        bool isVideo = f.isVideo;
+        bool fromPeer = f.fromPeer;
+        AVFrame* input = f.frame;
+        frames_.pop();
+
+        int streamIdx = (isVideo ? videoIdx_ : audioIdx_);
+        auto filter = (isVideo ? videoFilter_.get() : audioFilter_.get());
+        if (streamIdx < 0 || !filter) {
+            RING_ERR() << "Specified stream is invalid: "
+                << (fromPeer ? "remote " : "local ") << (isVideo ? "video" : "audio");
+            av_frame_free(&input);
+            continue;
+        }
+
+        // get filter input name if frame needs filtering
+        std::string inputName;
+        if (isVideo && nbReceivedVideoStreams_ == 2)
+            inputName = (fromPeer ? "v:main" : "v:overlay");
+        if (!isVideo && nbReceivedAudioStreams_ == 2)
+            inputName = (fromPeer ? "a:1" : "a:2");
+
+        // new reference because we are changing the timestamp
+        const MediaStream& ms = streams_[isVideo][fromPeer];
+        // stream has to start at 0
+        input->pts = input->pts - ms.firstTimestamp;
+
+        if (inputName.empty()) { // #nofilters
+            if (sendToEncoder(input, streamIdx) < 0) {
+                RING_ERR() << "Filed to encode frame";
+                av_frame_free(&input);
+                continue;
+            }
+        }
+
+        // empty filter graph output before sending more frames
+        emptyFilterGraph();
+
+        filter->feedInput(input, inputName);
+        av_frame_free(&input);
+    }
+}
+
 } // namespace ring
diff --git a/src/media/media_recorder.h b/src/media/media_recorder.h
index 23462ca853dded22eac7dd99e763b31cf25ff156..b60cea6f051b89057e29ae0c50b4d8e513efde40 100644
--- a/src/media/media_recorder.h
+++ b/src/media/media_recorder.h
@@ -25,10 +25,12 @@
 #include "media_filter.h"
 #include "media_stream.h"
 #include "noncopyable.h"
+#include "threadloop.h"
 
 #include <map>
 #include <memory>
 #include <mutex>
+#include <queue>
 #include <stdexcept>
 #include <string>
 #include <utility>
@@ -102,6 +104,20 @@ class MediaRecorder {
         bool isRecording_ = false;
         bool isReady_ = false;
         bool audioOnly_ = false;
+
+        struct RecordFrame {
+            AVFrame* frame;
+            bool isVideo;
+            bool fromPeer;
+            RecordFrame(AVFrame* f, bool v, bool p)
+                : frame(f)
+                , isVideo(v)
+                , fromPeer(p)
+            {}
+        };
+        ThreadLoop loop_;
+        void process();
+        std::queue<RecordFrame> frames_;
 };
 
 }; // namespace ring