Short version: with plain IF, IF runs now (at the compile time of the word you are defining); with POSTPONE IF, IF runs later (when the word you are defining is itself executed). Use plain IF to build a conditional in ordinary code; use POSTPONE IF when you are defining a new control-flow / compiling word that should lay down an IF for its future users.
The two layers to keep separate
A colon definition involves two execution times: the moment you compile the new word, and the later moment(s) you run it. The confusion around POSTPONE comes from mixing them up.
IF is an immediate word (more precisely, IF has compilation semantics that execute during compilation). When the compiler encounters IF while compiling some word, IF executes right then. Its compilation semantics: push an unresolved forward reference (an "orig") onto the control-flow stack and append a run-time conditional-branch into the word being built. That orig is later resolved by THEN/ELSE.
Plain IF — IF acts at this word's compile time
: ABS DUP 0< IF NEGATE THEN ;
Here IF fires while ABS is being compiled. It immediately compiles a conditional branch into ABS and hands the orig to THEN, which patches it. The result is that ABS itself contains a working conditional. This is the normal, everyday use of IF: you are writing a conditional inside ABS, and you want the branch built into ABS now. Nothing is deferred. (If you wrote POSTPONE IF here by mistake, you would not get a conditional in ABS at all.)
POSTPONE IF — IF's action is deferred to when the new word runs
POSTPONE name appends the compilation semantics of name to the current definition (ANS/ISO Forth CORE). So POSTPONE IF does not execute IF now; it compiles, into the word being defined, the instruction "execute IF's compilation semantics." IF therefore runs only when the new word is later executed — and at that later moment the new word is itself acting as a compiler, building an IF into whatever is being compiled then.
This is exactly what you need to define your own conditional/compiling words. Example: define a synonym ?IF that behaves like IF:
: ?IF POSTPONE IF ; IMMEDIATE
?IF is itself marked IMMEDIATE so it runs at compile time of its users. When a user writes ... ?IF ... THEN ..., ?IF executes, and executing it runs POSTPONE IF's deferred action: IF's compilation semantics fire, pushing an orig and compiling the branch into the user's word — just as a literal IF would have. Writing plain IF in that definition would instead make IF fire while ?IF itself was being compiled, building one conditional into ?IF once, which is not what a reusable conditional word wants.
Why POSTPONE rather than the old way
Because IF is immediate, you cannot compile a reference to it just by naming it (naming it executes it). Historically you used [COMPILE] IF; for non-immediate words you used COMPILE. POSTPONE unifies both: it appends a word's compilation semantics whether or not the word is immediate, so you no longer have to know or hard-code each word's immediacy. For an immediate word like IF, POSTPONE IF appends its compilation semantics so they run when the new (immediate) word runs; for a non-immediate word it effectively compiles a reference that runs that word's behavior later.
One-line mental model
IF = "do the IF thing here, now (while compiling this definition)."
POSTPONE IF = "compile a deferred order to do the IF thing later, when this definition runs as a compiler."
Sources: