November 2, 20254 min

Understanding Rust Move Closures: A Guide for JavaScript Developers

m
mayo

Coming from JavaScript? Closures work differently in Rust. A move closure forces ownership transfer of captured variables—no shared references like JS. This is the bridge between JavaScript's automatic closures and Rust's ownership model.

The JavaScript Baseline

In JavaScript, closures capture variables by reference automatically:

const makeCounter = () => {
  let count = 0;
  return () => count++; // captures `count` by reference
};

const counter = makeCounter();
console.log(counter()); // 0
console.log(counter()); // 1

The closure shares the same count variable. No copying, no moving—just a reference that lives as long as the closure does.

Rust's Explicit Choice

Rust makes you choose: borrow or own. Regular closures borrow:

let mut count = 0;
let increment = || count += 1; // borrows `count` mutably

move closures take ownership:

let count = 0;
let increment = move || count + 1; // `count` moved/copied into closure

Ownership Transfer Mechanics

For non-Copy types like String or Vec, the closure takes ownership:

let s = String::from("hello");
let closure = move || println!("{}", s); // `s` moved into closure
// println!("{}", s); // ERROR: `s` no longer valid

For Copy types like i32 or bool, the value is copied:

let x = 42;
let closure = move || println!("{}", x); // `x` copied
println!("{}", x); // OK: original `x` still valid

When You Need move

Threading

In JavaScript, you'd share state across async operations without thinking:

const data = [1, 2, 3];
setTimeout(() => {
  console.log(data); // just works
}, 100);

Rust threads must own their data:

use std::thread;

let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
    println!("{:?}", data); // `data` owned by thread
});
// println!("{:?}", data); // ERROR: moved
handle.join().unwrap();

Without move, the compiler rejects this—the thread might outlive data.

Returning Closures

JavaScript factories work by reference:

const makeAdder = (x) => (y) => x + y; // `x` captured by reference

const addFive = makeAdder(5);
console.log(addFive(3)); // 8

Rust closures must own what they outlive:

fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
    move |y| x + y // `x` must be moved
}

let add_five = make_adder(5);
println!("{}", add_five(3)); // 8

The closure outlives the function scope, so it needs ownership of x.

Async Blocks

Similar to threads, async blocks often need move when sent across tasks:

let value = String::from("async");
let future = async move {
    println!("{}", value);
};
// tokio::spawn requires 'static lifetime
tokio::spawn(future);

Borrow vs Own: The Core Difference

JavaScript closures always share:

let count = 0;
const increment = () => count += 1;
increment();
console.log(count); // 1 - same `count`

Rust regular closures borrow:

let mut count = 0;
let mut increment = || count += 1; // mutable borrow
increment();
println!("{}", count); // 1 - same `count`

Rust move closures own:

let mut count = 0;
let mut increment = move || count += 1; // `count` moved
increment();
// println!("{}", count); // ERROR: `count` moved

The moved count is independent—changes inside don't affect the original.

The Paradigm Shift from JavaScript

JavaScript: closures capture by reference implicitly. The GC manages lifetime. You never think about ownership:

const createHandler = () => {
  const state = { count: 0 };
  return () => state.count++; // reference lives as long as needed
};

Rust: you choose explicitly. Borrow for local use. Move for ownership transfer:

fn create_handler() -> impl FnMut() -> i32 {
    let mut state = 0;
    move || {
        state += 1;
        state
    } // `state` owned by closure
}

This prevents data races and use-after-free at compile time—guarantees JavaScript can't make.

Summary

Scenario Use move Reason
Threading Yes Thread may outlive scope
Returning closures Yes Closure outlives function
Async tasks Often Task needs 'static lifetime
Local use No Borrowing is sufficient

Core principle: If a closure outlives its environment or needs to be Send, use move. Otherwise, let the borrow checker choose the minimal capture mode.

The move keyword is Rust's way of saying: "This closure now owns these variables." It's not just syntax—it's a contract enforced at compile time, eliminating entire classes of runtime errors that plague languages with garbage collection.