cyberangles blog

Using @ndb.tasklet and @ndb.synctasklet in Google App Engine: Ensuring Async Operations Complete in POST Handlers with Yield

Google App Engine (GAE) provides a scalable platform for building web applications, and its Python runtime leverages the NDB (New Datastore API) library for efficient interaction with Cloud Datastore. A key challenge in web development is handling asynchronous (async) operations—such as database writes, external API calls, or file uploads—without blocking the main request thread. If mismanaged, async operations in critical flows like POST handlers can lead to incomplete data writes, lost updates, or inconsistent state, as the handler might send a response before the async task finishes.

NDB addresses this with @ndb.tasklet and @ndb.synctasklet decorators, which enable writing async code that guarantees completion before a response is sent. This blog dives deep into how these decorators work, their role in POST handlers, common pitfalls, and best practices to ensure reliable async operations.

2026-02

Table of Contents#

  1. Understanding NDB and Asynchronous Operations
  2. @ndb.tasklet: The Foundation of Async NDB Code
  3. @ndb.synctasklet: Bridging Async and Synchronous Handlers
  4. Why POST Handlers Require Special Care
  5. Step-by-Step: Using Tasklets in POST Handlers
  6. Common Pitfalls and How to Avoid Them
  7. Best Practices for Reliable Async Operations
  8. Conclusion
  9. References

1. Understanding NDB and Asynchronous Operations#

NDB is Google’s Python client library for Cloud Datastore, optimized for performance and scalability. It introduces asynchronous operations to avoid blocking the main thread while waiting for I/O (e.g., Datastore writes/reads, external API calls).

  • Synchronous vs. Asynchronous Operations:
    • Synchronous (Sync): The code waits for an operation to complete before proceeding (e.g., entity.put() blocks until the write is confirmed).
    • Asynchronous (Async): The code initiates an operation and continues executing, allowing other tasks to run in parallel (e.g., entity.put_async() returns a "future" representing the pending write).

Async operations improve throughput by utilizing idle time, but they require explicit coordination to ensure completion. NDB uses tasklets (lightweight coroutines) and futures (objects representing pending results) to manage this.

2. @ndb.tasklet: The Foundation of Async NDB Code#

@ndb.tasklet is a decorator that transforms a function into an async tasklet—a coroutine that returns a Future object. Tasklets use yield to pause execution and wait for other async operations (e.g., Datastore calls) to complete, enabling non-blocking code flow.

Key Concepts:#

  • Future: A placeholder for the result of an async operation. Use yield future to pause until the result is ready.
  • Yield: Within a tasklet, yield pauses execution and allows NDB to run other pending operations. When the yielded future completes, the tasklet resumes.
  • Return Values: Tasklets return futures. To get the actual result, use yield future or future.get_result() (after ensuring completion).

Example: Basic Tasklet for Async Datastore Read#

from google.appengine.ext import ndb  
 
class User(ndb.Model):  
    name = ndb.StringProperty()  
    email = ndb.StringProperty()  
 
@ndb.tasklet  
def get_user_async(user_id):  
    """Async tasklet to fetch a User entity by ID."""  
    user = yield User.get_by_id_async(user_id)  # Yield the future to wait for the result  
    raise ndb.Return(user)  # Return the result via ndb.Return (required in tasklets)  
 
# Usage:  
future = get_user_async(123)  
user = future.get_result()  # Blocks until the tasklet completes (use cautiously!)  

Why This Works:

  • User.get_by_id_async(123) returns a future.
  • yield pauses get_user_async until the future resolves, allowing NDB to handle other tasks in the meantime.
  • ndb.Return(user) packages the result into the tasklet’s future.

3. @ndb.synctasklet: Bridging Async and Sync Code#

Most web frameworks (e.g., webapp2, Flask) use synchronous handlers—functions that block until a response is sent. @ndb.synctasklet bridges async tasklets with sync code by running the tasklet and blocking until it completes, returning the actual result (not a future).

Key Use Case:#

In POST handlers, you need to ensure async writes (e.g., put_async()) complete before sending a response. @ndb.synctasklet lets you wrap async logic and call it like a sync function, guaranteeing completion.

Example: Synctasklet for Async Datastore Writes#

@ndb.synctasklet  
def create_user_synctasklet(name, email):  
    """Synctasklet to create a User entity asynchronously and return the key."""  
    user = User(name=name, email=email)  
    key_future = user.put_async()  # Start async write, get future  
    key = yield key_future  # Wait for the write to complete  
    raise ndb.Return(key)  # Return the completed key  
 
# Usage in a synchronous POST handler:  
def post(self):  
    name = self.request.get("name")  
    email = self.request.get("email")  
    user_key = create_user_synctasklet(name, email)  # Blocks until write completes  
    self.response.write(f"User created with ID: {user_key.id()}")  

Why This Works:

  • @ndb.synctasklet runs the tasklet, blocks until it finishes, and returns the result directly (user_key in this case).
  • The POST handler waits for create_user_synctasklet to complete, ensuring the Datastore write finishes before responding.

4. Why POST Handlers Require Special Care#

POST handlers often modify data (e.g., creating/updating entities). If async operations (e.g., put_async()) are not properly awaited, the handler may send a response before the write completes, leading to:

  • Lost data (the write fails silently).
  • Inconsistent state (subsequent reads don’t see the update).
  • Race conditions (concurrent writes overwrite each other).

@ndb.tasklet (with yield) and @ndb.synctasklet ensure all async operations in the handler complete before the response is sent.

5. Step-by-Step: Using Tasklets in POST Handlers#

Let’s walk through a real-world example: a POST handler that creates a User and a linked Profile entity, using async Datastore writes to maximize efficiency.

Step 1: Define Models#

class User(ndb.Model):  
    name = ndb.StringProperty(required=True)  
    email = ndb.StringProperty(required=True)  
 
class Profile(ndb.Model):  
    user_key = ndb.KeyProperty(kind=User, required=True)  
    bio = ndb.TextProperty()  
    created_at = ndb.DateTimeProperty(auto_now_add=True)  

Step 2: Create Async Tasklets for Writes#

We’ll create a tasklet to create both User and Profile asynchronously, then use @ndb.synctasklet to bridge to the sync handler.

@ndb.tasklet  
def create_user_and_profile_async(name, email, bio):  
    """Async tasklet to create User and Profile entities in parallel."""  
    # Start both writes (no yield yet—they run in parallel)  
    user = User(name=name, email=email)  
    user_future = user.put_async()  
 
    profile = Profile(bio=bio)  
    profile_future = profile.put_async()  
 
    # Wait for both writes to complete (yield multiple futures to run in parallel)  
    user_key, profile_key = yield user_future, profile_future  
    raise ndb.Return((user_key, profile_key))  
 
@ndb.synctasklet  
def create_user_and_profile_synctasklet(name, email, bio):  
    """Synctasklet wrapper to run the async tasklet and return results."""  
    user_key, profile_key = yield create_user_and_profile_async(name, email, bio)  
    raise ndb.Return((user_key, profile_key))  

Key Optimization: By starting both put_async() calls before yielding, we run them in parallel, reducing total latency.

Step 3: Use Synctasklet in the POST Handler#

import webapp2  
 
class UserProfileHandler(webapp2.RequestHandler):  
    def post(self):  
        # Extract request data  
        name = self.request.get("name")  
        email = self.request.get("email")  
        bio = self.request.get("bio")  
 
        # Validate input (simplified)  
        if not (name and email):  
            self.response.status = 400  
            self.response.write("Name and email are required.")  
            return  
 
        try:  
            # Run async tasklet via synctasklet (blocks until complete)  
            user_key, profile_key = create_user_and_profile_synctasklet(name, email, bio)  
            self.response.status = 201  
            self.response.write(f"User: {user_key.id()}, Profile: {profile_key.id()} created.")  
        except Exception as e:  
            # Handle errors (e.g., Datastore failure)  
            self.response.status = 500  
            self.response.write(f"Error: {str(e)}")  
 
app = webapp2.WSGIApplication([("/user/profile", UserProfileHandler)], debug=True)  

Why This Works:

  • create_user_and_profile_synctasklet blocks until both User and Profile writes complete.
  • The handler only sends a response after confirming the writes, preventing data loss.

6. Common Pitfalls and How to Avoid Them#

Pitfall 1: Forgetting to yield a Future#

Problem: If you call an async operation (e.g., put_async()) in a tasklet without yield, the operation may never complete.

Example (Bad):

@ndb.tasklet  
def bad_tasklet():  
    user = User(name="Alice")  
    user.put_async()  # Oops! No yield—write is scheduled but not awaited  
    raise ndb.Return("Done")  # Tasklet finishes before write completes  

Fix: Always yield the future to wait for completion:

@ndb.tasklet  
def good_tasklet():  
    user = User(name="Alice")  
    yield user.put_async()  # Wait for the write to finish  
    raise ndb.Return("Done")  

Pitfall 2: Using future.get_result() Prematurely#

Problem: Calling future.get_result() before the future completes raises an error.

Fix: Use yield future to ensure the future is ready before accessing the result.

Pitfall 3: Mixing Sync and Async Code Incorrectly#

Problem: Sync code (e.g., entity.put()) blocks the event loop, negating async benefits.

Fix: Use async variants (e.g., put_async()) within tasklets and yield their futures.

Pitfall 4: Ignoring Exceptions in Tasklets#

Problem: Async errors (e.g., Datastore timeouts) may go unhandled, causing silent failures.

Fix: Wrap yield statements in try/except blocks:

@ndb.tasklet  
def safe_tasklet():  
    try:  
        user = yield User.get_by_id_async(123)  
        raise ndb.Return(user)  
    except ndb.Timeout:  
        raise ndb.Return(None)  # Handle timeout gracefully  

7. Best Practices for Reliable Async Operations#

1. Batch Async Operations#

Start multiple async calls first, then yield their futures to run them in parallel:

@ndb.tasklet  
def batch_async():  
    # Start all operations  
    f1 = User.get_by_id_async(1)  
    f2 = User.get_by_id_async(2)  
    f3 = User.get_by_id_async(3)  
    # Wait for all to complete (parallel execution)  
    u1, u2, u3 = yield f1, f2, f3  
    raise ndb.Return([u1, u2, u3])  

2. Use synctasklet for Sync Handlers#

In frameworks like webapp2, use @ndb.synctasklet to run async code in sync handlers without manually managing futures.

3. Validate and Handle Errors#

Always validate input and catch exceptions in tasklets/handlers to avoid partial writes or unresponsive clients.

4. Test Async Code Thoroughly#

Use NDB’s test utilities (e.g., ndb.tests module) to simulate async operations and ensure completion.

8. Conclusion#

@ndb.tasklet and @ndb.synctasklet are critical tools for managing async operations in Google App Engine, especially in POST handlers where data integrity is paramount. By using tasklets with yield, you ensure async writes/reads complete before responding, preventing data loss and inconsistencies.

Key takeaways:

  • Use @ndb.tasklet for async logic, leveraging yield to wait for futures.
  • Use @ndb.synctasklet to bridge async tasklets with synchronous handlers.
  • Always yield futures to guarantee async operations complete.
  • Handle exceptions and validate input to ensure reliability.

9. References#