Skip to content

Instantly share code, notes, and snippets.

@matchdav
Created September 26, 2025 19:40
Show Gist options
  • Save matchdav/7c3aaa2f1314afc98002a2244f2f3d39 to your computer and use it in GitHub Desktop.
Save matchdav/7c3aaa2f1314afc98002a2244f2f3d39 to your computer and use it in GitHub Desktop.

We want to create a realistic illustration of how async generators can deal with timing issues in ad rendering.

Here we're considering the example of multiple ad slots for the same provider in a scrolling feed. In our example, a Repository can only initiate an ad fetch to populate the AdSlot component when the AdContainer component comes into view. What we want is an observer to emit a visible event from the AdContainer that signals the async iterator to emit the next ad index, which we need because the ad fetches are paginated. On the other end of this chain, when the AdSlot is rendered (on the visible AdContainer), we want to emit a viewed event with the correct ad slot data. This pattern effectively decouples the UI interaction (scrolling) from the data logic (fetching), creating a clean "pull-based" system from "push-based" UI events.

Here is a complete, runnable Node.js example that simulates this ad-loading flow.


The Simulation Setup

We'll simulate the components and events using classes and a central EventEmitter.

  1. adEvents (EventEmitter): A single, shared event bus for all components.
  2. AdContainer: Simulates a container that emits a 'containerVisible' event when it scrolls into view.
  3. AdSlot: Simulates the component that renders the ad and emits a 'adViewed' event.
  4. AdRepository: Simulates fetching ad data from a server.
  5. adIndexProvider (Async Generator): The core of our solution. It listens for 'containerVisible' events and yields the next ad index.
  6. main (Orchestrator): The main loop that consumes indices from the generator, fetches data, and renders the ads.
// This example requires Node.js to run.
import { EventEmitter } from 'node:events';
import { setTimeout } from 'node:timers/promises';

// A central event bus for our application
const adEvents = new EventEmitter();

// --- 1. The Simulated UI Components and Data Layer ---

class AdContainer {
  constructor(id) {
    this.id = id;
    this.adSlot = new AdSlot(this.id);
  }

  // Simulate this container scrolling into the viewport
  simulateBecameVisible() {
    console.log(`\nπŸ‘οΈ  Container #${this.id} is now VISIBLE.`);
    // Push an event to signal we need an ad
    adEvents.emit('containerVisible', { containerId: this.id });
  }
}

class AdSlot {
  constructor(id) {
    this.id = id;
  }
  
  // Render the ad data and fire a tracking event
  render(adData) {
    console.log(`   ➑️  AdSlot #${this.id} is RENDERING ad: "${adData.creative}"`);
    // Push a tracking event
    adEvents.emit('adViewed', { slotId: this.id, adData: adData });
  }
}

class AdRepository {
  // Simulate a paginated API call to fetch ad data
  async fetchAdByIndex(index) {
    console.log(`   ⏳  Fetching ad for index ${index}...`);
    await setTimeout(250); // Simulate network latency
    const adData = {
      id: `AD-${index}-XYZ`,
      creative: `Dynamic ad #${index + 1}: Buy Now!`,
      cpm: Math.random().toFixed(2),
    };
    console.log(`   βœ…  Fetched ad for index ${index}.`);
    return adData;
  }
}

// --- 2. The Core Async Generator ---

/**
 * An async generator that listens for 'containerVisible' events 
 * and yields the next sequential ad index. This is the bridge
 * between the UI event and the data-fetching logic.
 */
async function* adIndexProvider(emitter) {
  let currentIndex = 0;
  let resolveNext = null;

  const listener = () => {
    // When a 'visible' event occurs, open the promise "gate"
    if (resolveNext) {
      resolveNext();
      resolveNext = null;
    }
  };

  emitter.on('containerVisible', listener);

  try {
    while (true) {
      // Pause here and wait for the listener to call resolveNext()
      await new Promise((resolve) => {
        resolveNext = resolve;
      });
      // Once un-paused, yield the current index and increment
      yield currentIndex++;
    }
  } finally {
    // Important: Clean up the listener to prevent memory leaks
    emitter.off('containerVisible', listener);
    console.log('[Generator] Cleaned up and shut down.');
  }
}

// --- 3. The Orchestration Logic ---

async function main() {
  console.log('--- Ad Loading System Initialized ---');
  
  // Setup our application
  const repository = new AdRepository();
  const adContainers = [new AdContainer(1), new AdContainer(2), new AdContainer(3)];

  // Setup a listener for our final tracking event
  adEvents.on('adViewed', ({ slotId, adData }) => {
    console.log(`   πŸ“ˆ  TRACKING: Ad view for Slot #${slotId}, Creative ID: ${adData.id}`);
  });
  
  // Get the async iterator from our generator
  const adIndexIterator = adIndexProvider(adEvents);
  
  // This loop is the consumer. It will pause indefinitely at each
  // iteration until a 'containerVisible' event is emitted.
  for await (const adIndex of adIndexIterator) {
    console.log(`πŸš€  Main loop received ad index: ${adIndex}`);
    
    // We know an ad is needed, but which container needs it?
    // In a real app, the event payload would tell us. Here we'll just assume they appear in order.
    const targetContainer = adContainers[adIndex];
    if (!targetContainer) {
      console.log('--- All ad slots filled. Shutting down. ---');
      break; // Exit the loop if we run out of containers
    }

    try {
      const adData = await repository.fetchAdByIndex(adIndex);
      targetContainer.adSlot.render(adData);
    } catch (e) {
      console.error(`Failed to load ad for index ${adIndex}:`, e);
    }
  }
}


// --- 4. Simulate User Scrolling ---

async function simulateUserScrolling() {
  const containers = [new AdContainer(1), new AdContainer(2), new AdContainer(3)];
  
  // We need to pass the *same* containers to the main logic
  // so let's adjust the main function slightly to accept them.
  // This is a more realistic dependency injection pattern.
  
  // Start the main ad-loading logic in the background
  const adLoadingProcess = orchestrateAdLoading(containers);
  
  // Now, simulate the user scrolling down the page
  for (const container of containers) {
    await setTimeout(1500); // User scrolls...
    container.simulateBecameVisible();
  }
  
  // Wait for the main loop to finish (or it will run forever)
  // To stop it, we can call iterator.return()
  await setTimeout(500);
  adIndexIterator.return(); // This triggers the 'finally' block in the generator
  await adLoadingProcess;
}


// Modified main function to be more testable
async function orchestrateAdLoading(adContainers) {
  const repository = new AdRepository();
  // ... (setup tracking listener)
  adIndexIterator = adIndexProvider(adEvents); // a global iterator for simplicity here
  // ... (the for await loop)
  // ...
  // All the logic from the original `main` function would go here.
  // To avoid code duplication, I'll just call the original main and
  // start the simulation part that triggers it.
}

let adIndexIterator; // Let's make this accessible to stop it
const repository = new AdRepository();
const adContainers = [new AdContainer(1), new AdContainer(2), new AdContainer(3)];
adEvents.on('adViewed', ({ slotId, adData }) => {
    console.log(`   πŸ“ˆ  TRACKING: Ad view for Slot #${slotId}, Creative ID: ${adData.id}`);
});
adIndexIterator = adIndexProvider(adEvents);

const adLoadingProcess = (async () => {
    console.log('--- Ad Loading System Initialized ---');
    for await (const adIndex of adIndexIterator) {
        console.log(`πŸš€  Main loop received ad index: ${adIndex}`);
        const targetContainer = adContainers[adIndex];
        if (!targetContainer) {
            console.log('--- All ad slots filled. ---');
            break;
        }
        const adData = await repository.fetchAdByIndex(adIndex);
        targetContainer.adSlot.render(adData);
    }
})();

// This part simulates the user's actions, which drive the whole process.
(async () => {
    await setTimeout(1000);
    adContainers[0].simulateBecameVisible();

    await setTimeout(2000);
    adContainers[1].simulateBecameVisible();
    
    await setTimeout(1500);
    adContainers[2].simulateBecameVisible();

    await setTimeout(500);
    adIndexIterator.return(); // Gracefully stop the generator
    await adLoadingProcess;
    console.log('--- Simulation Complete ---');
})();

How It Works

  1. Initialization: The orchestrateAdLoading function starts. It creates the adIndexIterator and immediately enters the for await...of loop.
  2. The Pause: The loop pauses on the first iteration, waiting for the iterator to yield a value. Inside the generator, this corresponds to await new Promise(...). The entire ad-loading system is now idle, waiting for a signal.
  3. The "Push" Event: The simulation waits for 1.5 seconds, then calls adContainers[0].simulateBecameVisible(). This fires a 'containerVisible' event.
  4. The Bridge: The listener inside adIndexProvider hears this event and calls resolveNext(). This resolves the promise that the generator was awaiting.
  5. The "Pull" Action: The generator wakes up, yields the current index (0), and increments it. The for await loop receives the value 0 and its body begins to execute.
  6. Data Flow: The main loop calls the repository to fetch the ad, and upon success, tells the AdSlot to render it. The AdSlot then fires the final 'adViewed' tracking event.
  7. Repeat: The for await loop finishes its body and goes back to the top, again pausing and waiting for the next value from the iterator. The whole system goes idle again until the user scrolls and the next simulateBecameVisible() is called.
  8. Termination: Calling adIndexIterator.return() forces the generator to enter its finally block, which cleans up the event listener and allows the program to exit gracefully.

This architecture creates a beautiful separation of concerns. The view components (AdContainer) know nothing about the data layer, and the data layer knows nothing about the view. They are orchestrated by a central loop that pulls work to be done in response to pushed events from the UI.

@matchdav
Copy link
Author

Here's a similar example but for the browser.

const { useState, useEffect, useRef } = React;

// --- 1. UTILITIES & SERVICES ---

// Simple browser-compatible EventEmitter
class EventEmitter {
    constructor() { this.callbacks = {}; }
    on(event, cb) { (this.callbacks[event] = this.callbacks[event] || []).push(cb); }
    off(event, cb) { this.callbacks[event] = this.callbacks[event]?.filter(fn => fn !== cb); }
    emit(event, data) { (this.callbacks[event] || []).forEach(cb => cb(data)); }
}
const adEvents = new EventEmitter();

// Simple console logging to the screen
function log(message) {
    const logOutput = document.getElementById('log-output');
    logOutput.innerHTML += `${message}<br>`;
    logOutput.scrollTop = logOutput.scrollHeight;
}

// The Ad Repository is unchanged
class AdRepository {
    async fetchAdByIndex(index) {
        log(`⏳ Fetching ad #${index}...`);
        await new Promise(res => setTimeout(res, 500)); // Simulate network latency
        const adData = { id: `AD-${index}-XYZ`, creative: `Dynamic Ad #${index + 1}: Limited Time Offer!` };
        log(`βœ… Fetched ad #${index}.`);
        return adData;
    }
}

// The Async Generator is also unchanged
async function* adIndexProvider(emitter) {
    let currentIndex = 0;
    let resolveNext = null;
    const listener = () => { if (resolveNext) { resolveNext(); resolveNext = null; } };
    emitter.on('containerVisible', listener);
    try {
        while (true) {
            await new Promise((resolve) => { resolveNext = resolve; });
            yield currentIndex++;
        }
    } finally {
        emitter.off('containerVisible', listener);
        log('[Generator] Shut down.');
    }
}

// --- 2. REACT COMPONENTS ---

function AdSlot({ adData }) {
    useEffect(() => {
        if (adData) {
            // This is where you would fire a real tracking pixel
            adEvents.emit('adViewed', { adData });
        }
    }, [adData]); // Fire only when adData changes

    if (!adData) {
        return <div className="ad-slot loading"><h3>Ad Slot</h3><p>Waiting for ad...</p></div>;
    }

    return (
        <div className="ad-slot">
            <h3>{adData.creative}</h3>
            <p>Creative ID: {adData.id}</p>
        </div>
    );
}

function AdContainer({ adData }) {
    const containerRef = useRef(null);

    useEffect(() => {
        const observer = new IntersectionObserver(
            ([entry]) => {
                if (entry.isIntersecting) {
                    log(`πŸ‘οΈ Container is VISIBLE.`);
                    adEvents.emit('containerVisible');
                    observer.unobserve(entry.target); // Fire only once
                }
            },
            { threshold: 0.5 } // Trigger when 50% of the element is visible
        );

        if (containerRef.current) {
            observer.observe(containerRef.current);
        }

        return () => { if (containerRef.current) observer.unobserve(containerRef.current); };
    }, []); // Empty array ensures this runs only on mount

    return (
        <div className="ad-container" ref={containerRef}>
            <AdSlot adData={adData} />
        </div>
    );
}

function AdFeed() {
    // State to hold the ad data for all our slots
    const [ads, setAds] = useState([null, null, null]);

    useEffect(() => {
        const repository = new AdRepository();
        const adIndexIterator = adIndexProvider(adEvents);

        // This is the main orchestration logic
        const runAdLoader = async () => {
            log('πŸš€ Ad loader started.');
            for await (const adIndex of adIndexIterator) {
                if (adIndex >= ads.length) {
                        log('--- All slots filled. ---');
                        break;
                }
                const adData = await repository.fetchAdByIndex(adIndex);
                
                // Update the state with the new ad data
                setAds(currentAds => {
                    const newAds = [...currentAds];
                    newAds[adIndex] = adData;
                    return newAds;
                });
            }
        };

        // Setup tracking listener
        const trackingListener = ({ adData }) => log(`πŸ“ˆ TRACKING: Ad view for ${adData.id}`);
        adEvents.on('adViewed', trackingListener);

        runAdLoader();

        // Cleanup function: runs when the component unmounts
        return () => {
            adEvents.off('adViewed', trackingListener);
            // This is crucial to stop the async generator and prevent memory leaks
            adIndexIterator.return(); 
        };
    }, []); // Empty dependency array means this effect runs once on mount

    return (
        <div className="feed">
            <h1>Scrolling Feed</h1>
            <div className="content"><h2>Some Content</h2><p>Scroll down to see the ads load...</p></div>
            <AdContainer adData={ads[0]} />
            <div className="content"><h2>More Content</h2><p>Keep scrolling...</p></div>
            <AdContainer adData={ads[1]} />
            <div className="content"><h2>Even More Content</h2><p>Almost there...</p></div>
            <AdContainer adData={ads[2]} />
            <div className="content"><h2>End of Feed</h2></div>
        </div>
    );
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<AdFeed />);

@matchdav
Copy link
Author

Same again but reworked for Vue.

const { createApp, ref, onMounted, onUnmounted, watch } = Vue;

// --- 1. UTILITIES & SERVICES (Unchanged) ---

class EventEmitter {
    constructor() { this.callbacks = {}; }
    on(event, cb) { (this.callbacks[event] = this.callbacks[event] || []).push(cb); }
    off(event, cb) { this.callbacks[event] = this.callbacks[event]?.filter(fn => fn !== cb); }
    emit(event, data) { (this.callbacks[event] || []).forEach(cb => cb(data)); }
}
const adEvents = new EventEmitter();

function log(message) {
    const logOutput = document.getElementById('log-output');
    logOutput.innerHTML += `${message}<br>`;
    logOutput.scrollTop = logOutput.scrollHeight;
}

class AdRepository {
    async fetchAdByIndex(index) {
        log(`⏳ Fetching ad #${index}...`);
        await new Promise(res => setTimeout(res, 500));
        const adData = { id: `AD-${index}-XYZ`, creative: `Dynamic Ad #${index + 1}: Limited Time Offer!` };
        log(`βœ… Fetched ad #${index}.`);
        return adData;
    }
}

async function* adIndexProvider(emitter) {
    let currentIndex = 0;
    let resolveNext = null;
    const listener = () => { if (resolveNext) { resolveNext(); resolveNext = null; } };
    emitter.on('containerVisible', listener);
    try {
        while (true) {
            await new Promise((resolve) => { resolveNext = resolve; });
            yield currentIndex++;
        }
    } finally {
        emitter.off('containerVisible', listener);
        log('[Generator] Shut down.');
    }
}

// --- 2. VUE COMPONENTS ---

const AdSlot = {
    props: ['adData'],
    setup(props) {
        // 'watch' is the Vue equivalent of React's useEffect with a dependency.
        // It runs whenever the 'adData' prop changes.
        watch(() => props.adData, (newAdData) => {
            if (newAdData) {
                adEvents.emit('adViewed', { adData: newAdData });
            }
        });
    },
    template: `
        <div v-if="adData" class="ad-slot">
            <h3>{{ adData.creative }}</h3>
            <p>Creative ID: {{ adData.id }}</p>
        </div>
        <div v-else class="ad-slot loading">
            <h3>Ad Slot</h3>
            <p>Waiting for ad...</p>
        </div>
    `
};

const AdContainer = {
    components: { AdSlot },
    props: ['adData'],
    setup() {
        // In Vue, 'ref(null)' is used to get a reference to a template element.
        const containerRef = ref(null);
        
        // 'onMounted' is the hook for when the component is added to the DOM.
        onMounted(() => {
            const observer = new IntersectionObserver(
                ([entry]) => {
                    if (entry.isIntersecting) {
                        log(`πŸ‘οΈ Container is VISIBLE.`);
                        adEvents.emit('containerVisible');
                        observer.unobserve(entry.target); // Fire only once
                    }
                },
                { threshold: 0.5 }
            );
            
            if (containerRef.value) {
                observer.observe(containerRef.value);
            }
            
            // 'onUnmounted' handles cleanup when the component is destroyed.
            onUnmounted(() => {
                    if (containerRef.value) observer.unobserve(containerRef.value);
            });
        });

        return { containerRef };
    },
    template: `
        <div class="ad-container" ref="containerRef">
            <ad-slot :ad-data="adData" />
        </div>
    `
};

const app = createApp({
    components: { AdContainer },
    setup() {
        // 'ref' creates a reactive state variable.
        const ads = ref([null, null, null]);
        
        onMounted(() => {
            const repository = new AdRepository();
            const adIndexIterator = adIndexProvider(adEvents);
            
            const runAdLoader = async () => {
                log('πŸš€ Ad loader started.');
                for await (const adIndex of adIndexIterator) {
                    if (adIndex >= ads.value.length) {
                        log('--- All slots filled. ---');
                        break;
                    }
                    const adData = await repository.fetchAdByIndex(adIndex);
                    
                    // Update the reactive state. Vue will handle the re-render.
                    const newAds = [...ads.value];
                    newAds[adIndex] = adData;
                    ads.value = newAds;
                }
            };

            const trackingListener = ({ adData }) => log(`πŸ“ˆ TRACKING: Ad view for ${adData.id}`);
            adEvents.on('adViewed', trackingListener);
            
            runAdLoader();
            
            onUnmounted(() => {
                adEvents.off('adViewed', trackingListener);
                adIndexIterator.return(); // Crucial cleanup
            });
        });
        
        return { ads };
    },
    template: `
        <div class="feed">
            <h1>Scrolling Feed (Vue)</h1>
            <div class="content"><h2>Some Content</h2><p>Scroll down to see the ads load...</p></div>
            <ad-container :ad-data="ads[0]" />
            <div class="content"><h2>More Content</h2><p>Keep scrolling...</p></div>
            <ad-container :ad-data="ads[1]" />
            <div class="content"><h2>Even More Content</h2><p>Almost there...</p></div>
            <ad-container :ad-data="ads[2]" />
            <div class="content"><h2>End of Feed</h2></div>
        </div>
    `
});

app.mount('#app');

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment