Connect finished to queue_free, then call restart() after add_child(). In our tests, a one-shot effect added without that restart() never fired finished, so the node stayed in the scene.

The code
extends Node3D
## Spawn a one-shot effect from a scene and remove it when it ends.
@export var effect: PackedScene
func spawn_effect(at: Vector3) -> GPUParticles3D:
var fx := effect.instantiate() as GPUParticles3D
add_child(fx)
fx.global_position = at
fx.finished.connect(fx.queue_free)
fx.restart() # start it now, so finished always fires
return fx
We spawned 200 effects with this function, each with a lifetime of 0.4 seconds. After 1.5 seconds, none were left in the scene.
Why restart() is needed
We added a one-shot effect with add_child() alone, once as a new node and once from a PackedScene, then waited more than three times its lifetime. finished fired 0 times in both cases. The same kind of node fired it once after restart().
An effect made of several emitters
extends Node
## For an effect made of several one-shot emitters: free the root after the last one ends.
func play_and_free(root: Node) -> void:
var parts := root.find_children("*", "GPUParticles3D", true, false)
var left := [parts.size()] # an array: a lambda keeps its own copy of a plain int
for p in parts:
p.finished.connect(func():
left[0] -= 1
if left[0] == 0:
root.queue_free())
p.restart()
Every emitter under the root must be One Shot, because a looping emitter never fires finished.
Do not check emitting to see if an effect has ended. With Explosiveness at 1, it was already false 0.1 seconds into a 0.4 second effect. Our first version of this script did that and freed the effect too early.
Tested in Godot 4.7 with the Compatibility renderer.
Want ready-made effects? Try the free Arcane VFX pack or the VFX Collection for Godot 4.