diff --git a/DESCRIPTION b/DESCRIPTION
index a1a2370..d72cec6 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -1,7 +1,7 @@
 Package: gifski
 Type: Package
 Title: Highest Quality GIF Encoder
-Version: 0.8.6
+Version: 0.8.6.1
 Authors@R: c(
     person("Jeroen", "Ooms", ,"jeroen@berkeley.edu", role = c("aut", "cre"),
       comment = c(ORCID = "0000-0002-4035-0289")),
@@ -15,7 +15,7 @@ BugReports: https://github.com/r-rust/gifski/issues
 SystemRequirements: Cargo (rustc package manager)
 Encoding: UTF-8
 LazyData: true
-RoxygenNote: 6.0.1
+RoxygenNote: 6.1.0
 Suggests:
     ggplot2,
     gapminder
diff --git a/NAMESPACE b/NAMESPACE
index 3ce13d0..eddab9b 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -1,5 +1,9 @@
 # Generated by roxygen2: do not edit by hand
 
 export(gifski)
+export(gifski_encoder_init)
 export(save_gif)
+useDynLib(gifski,R_gifski_encoder_add_png)
+useDynLib(gifski,R_gifski_encoder_finalize)
+useDynLib(gifski,R_gifski_encoder_new)
 useDynLib(gifski,R_png_to_gif)
diff --git a/R/gifski.R b/R/gifski.R
index ed8e3c1..568e2f4 100644
--- a/R/gifski.R
+++ b/R/gifski.R
@@ -5,6 +5,7 @@
 #'
 #' @export
 #' @rdname gifski
+#' @family gifski
 #' @useDynLib gifski R_png_to_gif
 #' @param png_files vector of png files
 #' @param gif_file output gif file
diff --git a/R/stream.R b/R/stream.R
new file mode 100644
index 0000000..56d3b92
--- /dev/null
+++ b/R/stream.R
@@ -0,0 +1,41 @@
+#' Streaming Gifski Encoder
+#'
+#' Starts a stateful gif encoder and returns a closure to add png frames
+#' to the gif. Upon completion, the caller has to invoke the closure one
+#' more time with `png_file = NULL` to finalize the output.
+#'
+#' @export
+#' @inheritParams gifski
+#' @family gifski
+#' @name streaming
+#' @useDynLib gifski R_gifski_encoder_new
+gifski_encoder_init <- function(gif_file = "animation.gif", width = 800, height = 600, loop = TRUE, delay = 1){
+  gif_file <- normalizePath(gif_file, mustWork = FALSE)
+  if(!file.exists(dirname(gif_file)))
+    stop("Target directory does not exist:", dirname(gif_file))
+  width <- as.integer(width)
+  height <- as.integer(height)
+  delay <- as.integer(delay * 100)
+  loop <- as.logical(loop)
+  ptr <- .Call(R_gifski_encoder_new, enc2utf8(gif_file), width, height, loop)
+  function(png_file){
+    if(is.character(png_file)){
+      gifski_add_png(ptr, png_file, delay)
+    } else if(is.null(png_file)){
+      gifski_finalize(ptr)
+    } else {
+      stop("Invalid input: png_file must be a file path or NULL")
+    }
+  }
+}
+
+#' @useDynLib gifski R_gifski_encoder_add_png
+gifski_add_png <- function(ptr, png_file, delay){
+  png_file <- normalizePath(png_file, mustWork = TRUE)
+  .Call(R_gifski_encoder_add_png, ptr, enc2utf8(png_file), delay)
+}
+
+#' @useDynLib gifski R_gifski_encoder_finalize
+gifski_finalize <- function(ptr){
+  .Call(R_gifski_encoder_finalize, ptr)
+}
diff --git a/configure b/configure
index 7b7a7de..2449c0f 100755
--- a/configure
+++ b/configure
@@ -8,6 +8,13 @@ if [ $? -eq 0 ]; then
   VERSION=$($CARGO --version)
   echo "Using $CARGO ($VERSION)"
   sed -e "s|@cargobin@|$CARGO|" src/Makevars.in > src/Makevars
+
+  # CRAN forbids using $HOME during CMD check; try to override CARGO_HOME
+  if [ "$CARGO" == "/usr/bin/cargo" ] && [ "$_R_CHECK_SIZE_OF_TARBALL_" ]; then
+    if [ -z "CARGO_HOME" ] && [ ! -e "$HOME/.cargo" ]; then
+      sed -i.bak "s|#export|export|" src/Makevars
+    fi
+  fi
   exit 0
 fi
 
diff --git a/man/gifski.Rd b/man/gifski.Rd
index 9c2eedc..44931c4 100644
--- a/man/gifski.Rd
+++ b/man/gifski.Rd
@@ -5,8 +5,8 @@
 \alias{save_gif}
 \title{Gifski}
 \usage{
-gifski(png_files, gif_file = "animation.gif", width = 800, height = 600,
-  delay = 1, loop = TRUE, progress = TRUE)
+gifski(png_files, gif_file = "animation.gif", width = 800,
+  height = 600, delay = 1, loop = TRUE, progress = TRUE)
 
 save_gif(expr, gif_file = "animation.gif", width = 800, height = 600,
   delay = 1, loop = TRUE, progress = TRUE, ...)
@@ -67,3 +67,7 @@ gif_file <- file.path(tempdir(), 'gapminder.gif')
 save_gif(makeplot(), gif_file, 1280, 720, res = 144)
 utils::browseURL(gif_file)}
 }
+\seealso{
+Other gifski: \code{\link{streaming}}
+}
+\concept{gifski}
diff --git a/man/streaming.Rd b/man/streaming.Rd
new file mode 100644
index 0000000..c4fc10a
--- /dev/null
+++ b/man/streaming.Rd
@@ -0,0 +1,30 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/stream.R
+\name{streaming}
+\alias{streaming}
+\alias{gifski_encoder_init}
+\title{Streaming Gifski Encoder}
+\usage{
+gifski_encoder_init(gif_file = "animation.gif", width = 800,
+  height = 600, loop = TRUE, delay = 1)
+}
+\arguments{
+\item{gif_file}{output gif file}
+
+\item{width}{gif width in pixels}
+
+\item{height}{gif height in pixel}
+
+\item{loop}{should the gif play forever (FALSE to only play once)}
+
+\item{delay}{time to show each image in seconds}
+}
+\description{
+Starts a stateful gif encoder and returns a closure to add png frames
+to the gif. Upon completion, the caller has to invoke the closure one
+more time with `png_file = NULL` to finalize the output.
+}
+\seealso{
+Other gifski: \code{\link{gifski}}
+}
+\concept{gifski}
diff --git a/src/Makevars.in b/src/Makevars.in
index fd66959..e8ad2c6 100644
--- a/src/Makevars.in
+++ b/src/Makevars.in
@@ -4,7 +4,10 @@ PKG_CFLAGS = -pthread $(C_VISIBILITY)
 PKG_LIBS = -L$(LIBDIR) -lmyrustlib -lresolv -pthread
 CARGO = @cargobin@
 
-#all: clean
+# CRAN policy forbids using $HOME
+#export CARGO_HOME=$TMPDIR/.cargo
+
+all: clean
 
 $(SHLIB): $(STATLIB)
 
diff --git a/src/init.c b/src/init.c
new file mode 100644
index 0000000..80ec7ea
--- /dev/null
+++ b/src/init.c
@@ -0,0 +1,23 @@
+#define R_NO_REMAP
+#define STRICT_R_HEADERS
+#include <Rinternals.h>
+#include <R_ext/Rdynload.h>
+#include <R_ext/Visibility.h>
+
+extern SEXP R_gifski_encoder_add_png(SEXP, SEXP);
+extern SEXP R_gifski_encoder_finalize(SEXP);
+extern SEXP R_gifski_encoder_new(SEXP, SEXP, SEXP, SEXP);
+extern SEXP R_png_to_gif(SEXP, SEXP, SEXP, SEXP, SEXP, SEXP, SEXP);
+
+static const R_CallMethodDef CallEntries[] = {
+  {"R_gifski_encoder_add_png",  (DL_FUNC) &R_gifski_encoder_add_png,  2},
+  {"R_gifski_encoder_finalize", (DL_FUNC) &R_gifski_encoder_finalize, 1},
+  {"R_gifski_encoder_new",      (DL_FUNC) &R_gifski_encoder_new,      4},
+  {"R_png_to_gif",              (DL_FUNC) &R_png_to_gif,              7},
+  {NULL, NULL, 0}
+};
+
+attribute_visible void R_init_gifski(DllInfo *dll) {
+  R_registerRoutines(dll, NULL, CallEntries, NULL, NULL);
+  R_useDynamicSymbols(dll, FALSE);
+}
diff --git a/src/streamer.c b/src/streamer.c
new file mode 100644
index 0000000..23634cb
--- /dev/null
+++ b/src/streamer.c
@@ -0,0 +1,89 @@
+#define R_NO_REMAP
+#define STRICT_R_HEADERS
+#include <Rinternals.h>
+
+// Import C headers for rust API
+#include <pthread.h>
+#include <string.h>
+#include "myrustlib/gifski.h"
+
+/* data to pass to encoder thread */
+typedef struct {
+  int i;
+  char *path;
+  GifskiError err;
+  gifski *g;
+  GifskiSettings settings;
+  pthread_t encoder_thread;
+} gifski_ptr_info;
+
+/* gifski_write() blocks until main thread calls gifski_end_adding_frames() */
+static void * gifski_encoder_thread(void * data){
+  gifski_ptr_info * info = data;
+  info->err = gifski_write(info->g, info->path);
+  return NULL;
+}
+
+static void fin_gifski_encoder(SEXP ptr){
+  gifski_ptr_info *info = (gifski_ptr_info*) R_ExternalPtrAddr(ptr);
+  if(info == NULL)
+    return;
+  R_SetExternalPtrAddr(ptr, NULL);
+  if(info->encoder_thread)
+    pthread_cancel(info->encoder_thread);
+  gifski_drop(info->g);
+  free(info->path);
+  free(info);
+}
+
+static gifski_ptr_info *get_info(SEXP ptr){
+  if(TYPEOF(ptr) != EXTPTRSXP || !Rf_inherits(ptr, "gifski_encoder"))
+    Rf_error("pointer is not a gifski_encoder()");
+  if(!R_ExternalPtrAddr(ptr))
+    Rf_error("pointer is dead");
+  return R_ExternalPtrAddr(ptr);
+}
+
+SEXP R_gifski_encoder_new(SEXP gif_file, SEXP width, SEXP height, SEXP loop){
+  gifski_ptr_info *info = malloc(sizeof(gifski_ptr_info));
+  info->path = strdup(CHAR(STRING_ELT(gif_file, 0)));
+  info->settings.height = Rf_asInteger(height);
+  info->settings.width = Rf_asInteger(width);
+  info->settings.quality = 100;
+  info->settings.fast = false;
+  info->settings.once = !Rf_asLogical(loop);
+  info->err = GIFSKI_OK;
+  info->i = 0;
+  info->g = gifski_new(&info->settings);
+  if(pthread_create(&info->encoder_thread, NULL, gifski_encoder_thread, info))
+    Rf_error("Failed to create encoder thread");
+  SEXP ptr = R_MakeExternalPtr(info, R_NilValue, R_NilValue);
+  Rf_setAttrib(ptr, R_ClassSymbol, Rf_mkString("gifski_encoder"));
+  R_RegisterCFinalizerEx(ptr, fin_gifski_encoder, TRUE);
+  return ptr;
+}
+
+SEXP R_gifski_encoder_add_png(SEXP ptr, SEXP png_file, SEXP delay){
+  gifski_ptr_info *info = get_info(ptr);
+  if(info->err != GIFSKI_OK)
+    Rf_error("Gifski encoder is in bad state");
+  const char *path = CHAR(STRING_ELT(png_file, 0));
+  int d = Rf_asInteger(delay);
+  if(gifski_add_frame_png_file(info->g, info->i, path, d) != GIFSKI_OK)
+    Rf_error("Failed to add frame %d (%s)", info->i, path);
+  info->i++;
+  return Rf_ScalarInteger(info->i);
+}
+
+SEXP R_gifski_encoder_finalize(SEXP ptr){
+  gifski_ptr_info *info = get_info(ptr);
+  if(info->err != GIFSKI_OK)
+    Rf_error("Gifski encoder is in bad state");
+  if(gifski_end_adding_frames(info->g) != GIFSKI_OK)
+    Rf_error("Failed to finalizer encoder");
+  pthread_join(info->encoder_thread, NULL);
+  info->encoder_thread = NULL;
+  SEXP path = Rf_mkString(info->path);
+  fin_gifski_encoder(ptr);
+  return path;
+}
diff --git a/src/wrapper.c b/src/wrapper.c
index 73eab5e..076f110 100644
--- a/src/wrapper.c
+++ b/src/wrapper.c
@@ -1,8 +1,6 @@
 #define R_NO_REMAP
 #define STRICT_R_HEADERS
 #include <Rinternals.h>
-#include <R_ext/Rdynload.h>
-#include <R_ext/Visibility.h>
 
 // Import C headers for rust API
 #include <pthread.h>
@@ -69,14 +67,3 @@ SEXP R_png_to_gif(SEXP png_files, SEXP gif_file, SEXP width, SEXP height, SEXP d
     Rf_error("Failure writing image %s", info.path);
   return gif_file;
 }
-
-// Standard R package stuff
-static const R_CallMethodDef CallEntries[] = {
-  {"R_png_to_gif", (DL_FUNC) &R_png_to_gif, 7},
-  {NULL, NULL, 0}
-};
-
-attribute_visible void R_init_gifski(DllInfo *dll) {
-  R_registerRoutines(dll, NULL, CallEntries, NULL, NULL);
-  R_useDynamicSymbols(dll, FALSE);
-}