delay#
The delay operator shifts the emission of each next value from its source Observable forward in time by a specified duration.
Think of it like scheduled mail delivery:
- The source Observable "drops a letter in the mailbox" (
nextemission occurs). - The
delayoperator picks it up but holds onto it. - It waits for the specified time (e.g., 500 milliseconds).
- Then, it delivers the letter (emits the
nextvalue) downstream.
Completion follows the last delayed value. Errors are the exception: an error notification is NOT delayed; it tears through immediately, skipping any values still waiting in the delay buffer.
Key Points#
- Delays Emissions: It delays when the values are sent to the next operator or subscriber.
- Doesn't Delay Subscription: The subscription to the source happens immediately; only the emissions are postponed.
- Errors Are Not Delayed:
nextandcompleteare shifted; anerrorfires downstream immediately and discards pending delayed values. - Input: Takes a duration in milliseconds (e.g.,
delay(500)) or a specific futureDate.
At a glance
- Signature:
delay(ms)ordelay(date) - Use when: enforcing a minimum display time, simulating latency in demos and tests
- Avoid when: you want to rate-limit or coalesce emissions (
debounceTime/throttleTime) or delay per-value by a dynamic amount (delayWhen) - Top gotcha: errors skip the delay entirely, so "minimum display time" logic built on
delayalone does not apply to failure paths
Minimal Example#
import { delay, of } from "rxjs";
console.log("subscribing");
of("hello").pipe(delay(1000)).subscribe(console.log);
// subscribing
// (1 second later) hello
Why Use delay?#
- UI Polish: Simulate a minimum processing time. For example, if saving data is extremely fast, a "Saving..." message might just flash on and off. Using
delaycan ensure the message stays visible for at least, say, half a second, providing better user feedback. - Testing/Debugging: Introduce artificial latency into streams to test how your application handles timing issues or loading states.
- Simple Sequencing (Less Common): Ensure a small pause before an action occurs after an event (though more complex sequencing often uses other operators).
Real-World Example: Minimum Display Time for a "Saved" Message#
Imagine clicking a "Save" button. The backend operation might be incredibly fast (e.g., 50ms). If you immediately show and then hide a "Saved!" confirmation, the user might not even register it. Let's ensure the "Saved!" message stays visible for at least 750ms.
Code Snippet#
import {
Component,
inject,
signal,
ChangeDetectionStrategy,
DestroyRef,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import {
EMPTY,
Observable,
catchError,
delay,
finalize,
of,
switchMap,
tap,
timer,
} from "rxjs";
// Mock Service Function (simulates a quick backend save)
function mockSaveOperation(): Observable<{
success: boolean;
timestamp: number;
}> {
console.log("Backend: Starting simulated save...");
const saveSuccess = Math.random() > 0.2; // Simulate occasional failure
return of(saveSuccess).pipe(
delay(100), // Simulate VERY FAST network/backend time (100ms)
tap((success) =>
console.log(
`Backend: Simulated save ${success ? "successful" : "failed"}.`,
),
),
switchMap((success) => {
if (success) {
return of({ success: true, timestamp: Date.now() });
} else {
// Simulate an error being returned from backend
return timer(50).pipe(
switchMap(() => {
throw new Error("Save failed due to backend validation.");
}),
);
}
}),
);
}
@Component({
selector: "app-save-status",
template: `
<div>
<h4>Save Example with Delay</h4>
<button (click)="saveData()" [disabled]="saving()">Save Data</button>
@if (saving()) {
<p class="status saving">Saving...</p>
} @else if (statusMessage()) {
<p
class="status"
[class.success]="isSuccess()"
[class.error]="!isSuccess()"
>
{{ statusMessage() }}
</p>
}
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SaveStatusComponent {
private destroyRef = inject(DestroyRef);
// --- State Signals ---
saving = signal<boolean>(false);
statusMessage = signal<string | null>(null);
isSuccess = signal<boolean>(false);
saveData(): void {
if (this.saving()) return; // Prevent multiple saves
this.saving.set(true);
this.statusMessage.set(null); // Clear previous status
console.log('UI: Save initiated, showing "Saving..."');
const minimumDisplayTime = 750; // Ensure feedback shows for at least 750ms
mockSaveOperation()
.pipe(
tap({
next: (result) =>
console.log("UI Stream: Save operation successful (before delay)"),
error: (err) =>
console.error("UI Stream: Save operation failed (before delay)"),
}),
// --- Apply the delay ---
// Holds each NEXT value for minimumDisplayTime.
// NOTE: errors are NOT held; they skip the delay entirely.
delay(minimumDisplayTime),
// ---------------------
catchError((err: Error) => {
// Handle the error AFTER the delay
console.error("UI: Handling error after delay:", err.message);
this.isSuccess.set(false);
this.statusMessage.set(`Error: ${err.message}`);
// Return EMPTY to gracefully complete the stream for finalize
return EMPTY;
}),
// finalize runs after delay + next/error/complete
finalize(() => {
console.log('UI: Finalizing save operation (hiding "Saving...")');
this.saving.set(false);
}),
// Automatically unsubscribe when the component is destroyed
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: (result) => {
// Handle success AFTER the delay
console.log(`UI: Displaying success message after delay.`);
this.isSuccess.set(true);
this.statusMessage.set(
`Saved successfully at ${new Date(
result.timestamp,
).toLocaleTimeString()}`,
);
},
// Error is handled in catchError
// Complete isn't strictly needed here as finalize covers the loading state change
});
}
}
Explanation:
- When
saveData()is called,savingis set totrue, showing the "Saving..." message immediately. mockSaveOperation()is called. It simulates a quick backend response (completes in ~100ms) usingof(...)anddelay(100).- The result (or error) from
mockSaveOperationflows into the component's RxJS pipe. - The first
taplogs the immediate result from the "backend". delay(minimumDisplayTime): This is the key part. If the backend responded successfully (next),delayholds that success notification for 750ms before passing it on. If the backend errored, the error is NOT held: it reachescatchErrorimmediately.- After the delay (success) or immediately (error):
- If successful: The
nextnotification proceeds to thesubscribeblock'snexthandler after 750ms. The success message is displayed. - If an error occurred: The
errornotification skips the delay and proceeds straight tocatchError. The error message is displayed right away. (To give errors a minimum display time as well, delay the recovery instead:catchError(err => of(err).pipe(delay(minimumDisplayTime), ...).)
- If successful: The
finalize: This runs after the delayednextor the immediateerrorhas been processed (or if the stream unsubscribes). It setssavingtofalse, hiding the "Saving..." message.takeUntilDestroyed: Standard cleanup.
Because of delay(750), even though the backend might respond in 100ms, the success path won't update the UI until at least 750ms have passed, giving the user time to perceive the feedback.
Common Mistakes#
Assuming errors are delayed. They are not: delay forwards errors immediately and drops any values still waiting. Any "minimum time" or sequencing logic must handle the error path separately.
Using delay to space out emissions. delay(1000) shifts every value by the same offset; the gaps between values stay identical. Spacing values apart is concatMap((v) => of(v).pipe(delay(1000))) territory.
Reaching for delay when the trigger should wait, not the values. To postpone the whole subscription, use timer(ms).pipe(switchMap(() => source$)); delay subscribes to the source immediately.
Interview Q&A#
Does delay postpone the subscription or the emissions?
The emissions. The source is subscribed (and starts its work, like an HTTP call) immediately; each next value is then held for the configured duration on its way downstream.
Which notifications does delay affect?
next values are delayed and complete waits for the last delayed value. error is passed through immediately and cancels pending delayed values, a detail that distinguishes strong candidates.
How do you delay each value relative to the previous one instead of by a fixed offset?
Wrap the per-value delay inside a sequential higher-order operator: concatMap((v) => of(v).pipe(delay(gap))) emits values gap apart, whereas plain delay(gap) shifts the entire original timing by one constant.
Related#
- timer to create delayed sources instead of shifting existing ones
- debounceTime for delay-based coalescing of bursts
- concatMap for per-value sequential delays