Command encoders and the queue: recording work, then submitting
WebGPU never executes a draw the instant you call it — instead you record a whole batch of commands into a command encoder, finish it into an immutable command buffer, and submit that buffer to the queue in one call, which is the concrete mechanism behind the module's opening claim that WebGPU makes the CPU-to-GPU handoff cheap.
Command encoders and the queue: recording work, then submitting
Every lesson so far has built objects — a pipeline, bind groups — but none of them have actually drawn anything, and that gap is deliberate. WebGL's drawArrays pokes the driver the instant you call it, on whatever's currently bound. WebGPU does the opposite: you record a batch of commands into a GPUCommandEncoder, turn that recording into an immutable GPUCommandBuffer, and only then submit it to device.queue. Nothing touches the GPU until the submit call. This lesson is the mechanism, and it's the direct, concrete answer to the CPU-side cost the draw-calls lesson spent an entire lesson measuring.
Opening the encoder
const encoder = device.createCommandEncoder();A GPUCommandEncoder isn't connected to the GPU yet — it's a builder object living entirely on the CPU side, accumulating a list of commands you tell it about. Nothing you call on it executes anything; every method just appends another entry to the batch it's building.
A render pass: beginRenderPass
Drawing happens inside a render pass, opened with a descriptor naming the color and depth targets it will write to:
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: canvasTextureView,
loadOp: "clear",
clearValue: { r: 0.05, g: 0.05, b: 0.08, a: 1.0 },
storeOp: "store",
},
],
depthStencilAttachment: {
view: depthTextureView,
depthLoadOp: "clear",
depthClearValue: 1.0,
depthStoreOp: "store",
},
});Each attachment declares two things that decide what happens to it, before and after the pass. loadOp says what the attachment contains at the start of the pass: "clear" wipes it to clearValue first (the common case at the start of a frame), while "load" preserves whatever was already there — useful when a later pass needs to keep drawing on top of an earlier one instead of starting fresh. storeOp says what happens at the end: "store" keeps the result in memory (so it can be displayed or read by a later pass), while "discard" throws it away, which is a real optimization when a pass only produced an intermediate result nothing else needs. Both loadOp and storeOp are settled once, in the descriptor, the same "declare it up front" instinct as the render pipeline's fixed state.
Recording draws into the pass
Everything from here should look familiar, because it's exactly the API the last two lessons built toward:
pass.setPipeline(pipeline);
pass.setBindGroup(0, cameraBindGroup);
pass.setBindGroup(1, materialBindGroup);
pass.setVertexBuffer(0, vertexBuffer);
pass.setIndexBuffer(indexBuffer, "uint16");
pass.drawIndexed(indexCount);None of these calls draw anything either. setPipeline, setBindGroup, setVertexBuffer, setIndexBuffer, and draw/drawIndexed are all just further entries appended to the same recording — the encoder is building up a description of "here is everything that should happen," not causing any of it to happen yet. You can call this sequence many times inside one pass, once per object you're drawing, switching pipelines and bind groups between them exactly as needed.
Ending the pass, finishing the buffer
pass.end();
const commandBuffer = encoder.finish();pass.end() closes off the render pass — no more draws can be recorded into it. encoder.finish() seals the entire recording into a GPUCommandBuffer: an immutable, fully-formed description of every command you recorded, ready to hand to the GPU. A command buffer is one-shot. You call finish() exactly once per encoder, and once you have the buffer you cannot record more into it or into the encoder that made it — if you need another batch of work, you create a new encoder and start again.
Submitting: the one call that actually reaches the GPU
device.queue.submit([commandBuffer]);This is the moment, and the only moment, in this entire sequence where anything crosses from the CPU to the GPU. Every setPipeline, every setBindGroup, every draw you recorded above was pure CPU-side bookkeeping — building a data structure — until this single call hands the whole finished batch across at once. submit takes an array, so a single submission can bundle multiple command buffers (and, within one buffer, multiple passes) together; the GPU processes them in order, but the CPU only paid the cost of the handoff once.
Why this is faster: batching the CPU→GPU crossing itself
Recall the core finding from the draw-calls lesson: a scene bottlenecked on draw calls is bottlenecked on the CPU — on how fast the main thread can validate state and dispatch commands one at a time, each crossing the boundary between JavaScript and the driver separately. That's Problem 2 from the flagship lesson again, this time aimed at the act of submission itself rather than at pipeline state: WebGL's drawArrays pokes the driver on every single call, so a thousand draws means a thousand separate crossings of a boundary that is itself not free.
WebGPU's record-then-submit model collapses that. You build up an arbitrarily long list of commands — a hundred draws, a thousand — entirely on the CPU, as cheap in-process bookkeeping, and cross the CPU→GPU boundary exactly once, at submit, no matter how many commands the buffer contains. The per-command cost inside the recording is close to free (it's just appending to a list); the expensive part, the actual hop across the boundary, happens once per submission instead of once per draw. Fewer, batched submissions mean more of every 16.6ms frame is left for real work — your own game logic, animation, or scene traversal — instead of being spent shuttling one command at a time across a boundary that charges per crossing.
One submit, multiple passes
A single submit call is not limited to one render pass. You can beginRenderPass, record, end, then beginRenderPass again on the same encoder (a shadow pass, then a main color pass, for instance), and finish() the encoder once at the end — one command buffer containing several passes, submitted together. The boundary you're minimizing is the submit call, not the pass — passes are just how you organize distinct render targets within the batch you're about to send.
Where this goes next
Every piece of this lesson's machinery — encoder, finish(), queue.submit() — is not specific to rendering. Compute shaders reuses the exact same record-then-submit pattern, just with beginComputePass() in place of beginRenderPass() and dispatchWorkgroups in place of draw. That lesson is where the module's other big claim — that a GPU is a general-purpose parallel processor, not just a triangle-drawing peripheral — finally gets to run without any triangles at all.
Go deeper
- MDN — GPUCommandEncoder — The full set of recording methods (render passes, compute passes, buffer/texture copies) an encoder supports, beyond what this lesson used.
- MDN — GPUQueue.submit() — Reference for submit's array argument and ordering guarantees across multiple command buffers.
- WebGPU Fundamentals — Builds the same encoder-to-pass-to-submit sequence from scratch with a runnable first triangle.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- What does device.createCommandEncoder() return, and does calling setPipeline or draw on a pass built from it touch the GPU immediately?
- Explain what loadOp and storeOp each control on a render pass color attachment, and give a case where you'd want 'load' instead of 'clear'.
- What does encoder.finish() produce, and why can you not record more commands into that encoder afterward?
- Which single call in the whole record-record-record-finish-submit sequence is the one moment work actually reaches the GPU?
- Tie this lesson back to the draw-calls lesson: what CPU-bound cost does batching many commands into one submit reduce, and what cost does it not reduce?
- Can one queue.submit() call contain multiple render passes? What does that let you organize within a single CPU→GPU handoff?
- Why is a command buffer described as 'one-shot,' and what do you have to create fresh if you need to record another batch of work?