The Complete Guide to Object Pooling - Killing Stutter in Godot Through Reuse

Created: 2025-12-10Last updated: 2026-07-09

Object pooling in Godot, explained to remove instantiation spikes. Covers reusing objects instead of creating and destroying them, the trap where _ready() isn't called, fully stopping processing and collisions, building a bullet pool, and deciding when to adopt it, with diagrams and code.

You unleash a wall of bullets in a shmup, or an enemy drops a burst of coins, and for that one instant the frame rate drops. Have you run into that kind of sudden stutter (spike)?

The usual cause is creation (instantiate()) and destruction (queue_free()) piling up in a short window. The cost of a single creation is tiny, but repeat it hundreds of times per second and it puts real load on the CPU. Object pooling fixes this at the root. This article covers how it works, the traps that trip people up, a bullet pool implementation, and how to decide whether you should adopt it at all.

Object pooling illustration showing spent bullets and effects returned to a pool for reuse instead of being destroyed

What You'll Learn

  • What causes spikes, and how object pooling removes them through reuse
  • The trap where _ready() isn't called on reuse, and initializing with spawn() / reset()
  • Why hide() isn't enough, and how to fully stop processing and collisions
  • A bullet pool implementation, and how to decide whether to adopt it (compared to the alternatives)

Sponsored

How Object Pooling Works: Reuse Instead of Discard

The idea behind object pooling is simple: don't throw objects away, reuse them. It's the same as a restaurant washing plates instead of making new ones every time. It pushes the most expensive parts, creation and destruction, out of active gameplay.

Four-step object pooling cycle diagram: 1. pre-create objects and park them in the pool, 2. take one out and show and enable it, 3. use it in the game (the bullet flies), 4. when it's done, hide it and return it to the pool instead of destroying it

It's a four-step cycle.

  1. Pre-create: at a moment where load is acceptable, such as game start, create a fixed number of objects, store them in a pool (standby list), and hide them
  2. Take one out: when you need an object, like firing a bullet, don't instantiate(). Take an unused one from the pool, initialize its position, and show it
  3. Use it: the object does its job in the game (the bullet flies and hits an enemy)
  4. Return it: when it's done, don't destroy it with queue_free(). Hide it and put it back in the pool

With this cycle, the in-game cost drops to the light work of resetting position and state. Spikes from creation and destruction disappear, and the frame rate stabilizes.

Frame time comparison between create/destroy and pooling: on the left, instantiate/queue_free produces frequent frame time spikes on every shot; on the right, object pooling keeps frame time low and flat

Common Traps and Best Practices

Object pooling looks easy, but it has a few pitfalls. Initialization specific to reuse is the one to watch.

Diagram of the trap where _ready() isn't called on reuse: the first time, instantiate and adding to the tree calls _ready(), but reusing from the pool does not, so initialization must go in a dedicated method such as spawn() or reset()
Common mistakeBest practice
Initializing in _ready()_ready() is only called once. Do reuse-time initialization in a dedicated method like spawn(position, direction)
Returning with just hide()hide() leaves _process and collisions running. Stop everything with set_process_mode(Node.PROCESS_MODE_DISABLED) and disable the CollisionShape
Calling remove_child() during physicsTree operations during physics are unstable. Defer them to a safe moment with call_deferred()
Ignoring pool exhaustionRunning dry during an intense scene breaks things. Add a fallback that creates one on the spot plus a printerr so you notice the shortfall
Forgetting to reset stateReset everything to its initial value, not just position and velocity but color (modulate), scale, and custom variables. Missed resets breed bizarre bugs
Diagram showing why hide() alone is insufficient: on the left, hide() only, so _process and collision detection keep running even though nothing is visible (wasted load and invisible hitboxes); on the right, PROCESS_MODE_DISABLED stops processing and the CollisionShape is disabled for a full standby state

Objects that are invisible but still running deserve special attention. If a hidden bullet keeps moving and checking collisions in the background, you not only fail to reduce load, you create bugs from invisible hitboxes. Properly stopping processing and collisions is essential.

Sponsored

Hands-On: A Bullet Pool That Doesn't Slow Down

Bullets in a shmup, spark effects on hit, coins dropped by enemies: anything that spawns and disappears at high frequency is an application of this bullet pool. We'll build it from two pieces: a PoolManager that manages all pools (registered as an Autoload) and the PooledBullet that gets pooled.

The Heart of the Pool: PoolManager

Register PoolManager as an Autoload so you can call it from anywhere. It handles taking objects out (get_object), returning them (return_object), and enabling and disabling them.

# pool_manager.gd (registered as an Autoload)
extends Node

@export var scene_templates: Dictionary = {}     # Key: scene path, Value: PackedScene
@export var initial_pool_sizes: Dictionary = {}   # Key: scene path, Value: initial count

var pools: Dictionary = {}

func _ready() -> void:
    for scene_path in scene_templates:
        var scene: PackedScene = scene_templates[scene_path]
        var size: int = initial_pool_sizes.get(scene_path, 10)
        pools[scene_path] = []
        for i in size:
            var obj := scene.instantiate()
            if obj.has_method("set_pool_manager"):
                obj.set_pool_manager(self)
            add_child(obj)
            _deactivate(obj)          # Put it on standby right after creation
            pools[scene_path].append(obj)

func get_object(scene_path: String) -> Node:
    # If the pool is empty, create one on the spot (fallback plus warning)
    if not pools.has(scene_path) or pools[scene_path].is_empty():
        printerr("Pool '%s' is empty. Creating a new one (the size may be too small)" % scene_path)
        var new_obj := (scene_templates[scene_path] as PackedScene).instantiate()
        if new_obj.has_method("set_pool_manager"):
            new_obj.set_pool_manager(self)
        add_child(new_obj)
        return new_obj
    var obj: Node = pools[scene_path].pop_back()
    _activate(obj)                    # Enable it once taken out
    return obj

func return_object(obj: Node, scene_path: String) -> void:
    if obj in pools.get(scene_path, []):   # Prevent double returns
        return
    _deactivate(obj)                  # Stop it before returning it
    pools[scene_path].append(obj)

func _activate(obj: Node) -> void:
    obj.show()
    obj.process_mode = Node.PROCESS_MODE_INHERIT   # Resume processing
    _set_collisions_disabled(obj, false)

func _deactivate(obj: Node) -> void:
    obj.hide()
    obj.process_mode = Node.PROCESS_MODE_DISABLED  # Stop all processing
    _set_collisions_disabled(obj, true)
    if obj.has_method("reset"):
        obj.reset()                   # Return state to its initial values

# Enable/disable all descendant CollisionShapes at once
func _set_collisions_disabled(node: Node, disabled: bool) -> void:
    for child in node.get_children():
        if child is CollisionShape2D or child is CollisionShape3D:
            child.set_deferred("disabled", disabled)   # Deferred for safety during physics
        _set_collisions_disabled(child, disabled)

The Pooled Side: PooledBullet

On the bullet side, the keys are spawn() (initialization) and reset() (state restoration) replacing _ready(), plus the logic that returns itself to the pool. Use the signal from VisibleOnScreenNotifier2D to return automatically when the bullet leaves the screen.

# pooled_bullet.gd
extends CharacterBody2D

const SPEED := 800.0
var direction := Vector2.RIGHT
var _pool_manager: Node

@onready var notifier: VisibleOnScreenNotifier2D = $VisibleOnScreenNotifier2D

func _ready() -> void:
    # Return myself to the pool when I leave the screen (_ready() runs once, so connecting here is fine)
    notifier.screen_exited.connect(return_to_pool)

func set_pool_manager(manager: Node) -> void:
    _pool_manager = manager

# Initialization in place of _ready(). Called every time we're taken out
func spawn(start_position: Vector2, travel_direction: Vector2) -> void:
    global_position = start_position
    direction = travel_direction.normalized()
    rotation = direction.angle()

# Return state to its initial values when returned (watch for missed resets)
func reset() -> void:
    velocity = Vector2.ZERO

func _physics_process(_delta: float) -> void:
    velocity = direction * SPEED
    move_and_slide()

func return_to_pool() -> void:
    if _pool_manager:
        # Avoid returning during physics; defer for safety
        _pool_manager.call_deferred("return_object", self, scene_file_path)
    else:
        queue_free()

The firing side simply calls get_object() and spawn() instead of instantiate().

# Firing (PoolManager is the Autoload name)
var bullet := PoolManager.get_object("res://bullet.tscn")
bullet.spawn($Muzzle.global_position, Vector2.RIGHT)

There are two key points.

  • Initialize in spawn(), clean up in reset(): _ready() isn't called on reuse, so set position and direction in spawn() every time you take an object out, and reset velocity and the like in reset() when returning it. These two are the lifeline of reuse.
  • Return safely with call_deferred(): a bullet hits an enemy in the middle of physics. Calling return_object() right there makes tree operations unstable, so call_deferred() queues it for after the physics step completes.
Sponsored

Should You Adopt It? Measurement and Alternatives

Object pooling is powerful, but it isn't a cure-all. Godot 4 made node creation considerably faster, so if you only create something once every few frames, building a pool can cost more effort than it saves.

Diagram of the adoption decision and alternatives: first confirm with the Profiler whether instantiation is the bottleneck, then choose object pooling for short-lived high-frequency objects, MultiMesh/RenderingServer for thousands of visual-only items, and Thread for heavy computation

Always measure with the profiler and adopt it only after confirming that instantiation really is the bottleneck. From there, pick the tool that matches the nature of the problem.

  • Object pooling: for spikes from short-lived, high-frequency nodes like bullets, effects, and coins
  • MultiMesh / RenderingServer: for visual-only volume (thousands to tens of thousands) with simple logic. Drawing directly without nodes is dramatically faster, but you have to implement things like collision yourself
  • Thread: for CPU-heavy work like pathfinding or large computations, moved off to another thread. A fix for a different kind of load than spikes

Match the tool to the symptom: identical nodes spawning and vanishing at high frequency means pooling, visual-only mass rendering means MultiMesh, and heavy computation means threads.

Bonus: Good to Know for Later

  • Decide pool size by measuring: if the printerr fallback warning fires often, your initial size is too small. Tune it against the peak count in your most intense scene.
  • Effects can be pooled too: one-shot GPUParticles2D effects can go in a pool as well, reused by toggling emitting.
  • Combine it with culling: the idea of stopping processing for off-screen objects (with VisibleOnScreenEnabler2D, for instance) pairs well with pooling and cuts load further.

Summary

  • Object pooling removes spikes caused by bursts of instantiate() / queue_free() and stabilizes FPS
  • It targets short-lived, high-frequency objects like bullets and effects
  • _ready() isn't called on reuse, so initialize manually with spawn() / reset()
  • Go beyond hide() and fully stop processing with PROCESS_MODE_DISABLED plus disabled collisions. Return objects safely with call_deferred()
  • Adopt it only after confirming with the profiler. Use MultiMesh for mass rendering and Thread for heavy computation

Start by pooling a single bullet type and checking that frame time stays flat no matter how much you fire. Once you feel the spikes disappear, object pooling becomes one of your go-to tools.

Further Reading